refactor: swap-core / swap-machine (#530)

* progress

* fix thread safety

* move monero types from swap into swap_core

* just fmt

* move non test code above test code

* revert removed tracing in bitcoin-wallet/src/primitives.rs

* Use existing private_key_from_secp256k1_scalar

* remove unused monero chose code

* fix some clippy warnings due to imports

* move state machine types into the new `swap-machine` crate

* remove monero_c orphan submodule

* rm bdk_test and sqlx_test from ci

* move proptest.rs into swap-proptest

* increase stack size to 12mb

* properly increase stack size

* fix merge conflict in ci.yml

* don't increase stack size on mac

* fix infinite recursion

* fix integration tests

* fix some compile errors

* fix compilation errors

* rustfmt

* ignore unstaged patches we applied to monero submodule when running git status

* fix some test compilation errors

* use BitcoinWallet trait instead of concrete type everywhere

* add just test command to run integration tests

* remove test_utils features from bdk in swap-core

---------

Co-authored-by: einliterflasche <einliterflasche@pm.me>
Co-authored-by: binarybaron <binarybaron@mail.mail>
This commit is contained in:
Mohan 2025-10-10 16:29:00 +02:00 committed by GitHub
parent 908308366b
commit 4ae47e57f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 3651 additions and 3007 deletions

1
.gitmodules vendored
View file

@ -1,6 +1,7 @@
[submodule "monero-sys/monero"]
path = monero-sys/monero
url = https://github.com/mrcyjanek/monero
ignore = dirty
[submodule "monero-sys/monero-depends"]
path = monero-sys/monero-depends
url = https://github.com/eigenwallet/monero-depends.git

983
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
[workspace]
resolver = "2"
members = [
"bitcoin-wallet",
"electrum-pool",
"libp2p-rendezvous-server",
"libp2p-tor",
@ -13,9 +14,11 @@ members = [
"swap-asb",
"swap-controller",
"swap-controller-api",
"swap-core",
"swap-env",
"swap-feed",
"swap-fs",
"swap-machine",
"swap-orchestrator",
"swap-serde",
"throttle",
@ -30,6 +33,21 @@ bdk_electrum = { version = "0.23.0", default-features = false }
bdk_wallet = "2.0.0"
bitcoin = { version = "0.32", features = ["rand", "serde"] }
# Cryptography
curve25519-dalek = { version = "4", package = "curve25519-dalek-ng" }
ecdsa_fun = { version = "0.10", default-features = false, features = ["libsecp_compat", "serde", "adaptor"] }
rand = "0.8"
# Randomness
rand_chacha = "0.3"
sha2 = "0.10"
sigma_fun = { version = "0.7", default-features = false }
# Async
async-trait = "0.1"
# Serialization
serde_cbor = "0.11"
anyhow = "1"
backoff = { version = "0.4", features = ["futures", "tokio"] }
futures = { version = "0.3", default-features = false, features = ["std"] }
@ -38,7 +56,6 @@ jsonrpsee = { version = "0.25", default-features = false }
libp2p = { version = "0.53.2" }
monero = { version = "0.12", features = ["serde_support"] }
once_cell = "1.19"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json"] }
rust_decimal = { version = "1", features = ["serde-float"] }
rust_decimal_macros = "1"

12
bitcoin-wallet/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "bitcoin-wallet"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
bdk_wallet = { workspace = true }
bitcoin = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

65
bitcoin-wallet/src/lib.rs Normal file
View file

@ -0,0 +1,65 @@
pub mod primitives;
pub use crate::primitives::{ScriptStatus, Subscription, Watchable};
use anyhow::Result;
use bdk_wallet::{export::FullyNodedExport, Balance};
use bitcoin::{Address, Amount, Network, Psbt, Txid, Weight};
#[async_trait::async_trait]
pub trait BitcoinWallet: Send + Sync {
async fn balance(&self) -> Result<Amount>;
async fn balance_info(&self) -> Result<Balance>;
async fn new_address(&self) -> Result<Address>;
async fn send_to_address(
&self,
address: Address,
amount: Amount,
spending_fee: Amount,
change_override: Option<Address>,
) -> Result<Psbt>;
async fn send_to_address_dynamic_fee(
&self,
address: Address,
amount: Amount,
change_override: Option<Address>,
) -> Result<bitcoin::psbt::Psbt>;
async fn sweep_balance_to_address_dynamic_fee(
&self,
address: Address,
) -> Result<bitcoin::psbt::Psbt>;
async fn sign_and_finalize(&self, psbt: bitcoin::psbt::Psbt) -> Result<bitcoin::Transaction>;
async fn broadcast(
&self,
transaction: bitcoin::Transaction,
kind: &str,
) -> Result<(Txid, Subscription)>;
async fn sync(&self) -> Result<()>;
async fn subscribe_to(&self, tx: Box<dyn Watchable>) -> Subscription;
async fn status_of_script(&self, tx: &dyn Watchable) -> Result<ScriptStatus>;
async fn get_raw_transaction(
&self,
txid: Txid,
) -> Result<Option<std::sync::Arc<bitcoin::Transaction>>>;
async fn max_giveable(&self, locking_script_size: usize) -> Result<(Amount, Amount)>;
async fn estimate_fee(&self, weight: Weight, transfer_amount: Option<Amount>)
-> Result<Amount>;
fn network(&self) -> Network;
fn finality_confirmations(&self) -> u32;
async fn wallet_export(&self, role: &str) -> Result<FullyNodedExport>;
}

View file

@ -0,0 +1,255 @@
use anyhow::Result;
use bitcoin::{FeeRate, ScriptBuf, Txid};
/// An object that can estimate fee rates and minimum relay fees.
pub trait EstimateFeeRate {
/// Estimate the fee rate for a given target block.
fn estimate_feerate(
&self,
target_block: u32,
) -> impl std::future::Future<Output = Result<FeeRate>> + Send;
/// Get the minimum relay fee.
fn min_relay_fee(&self) -> impl std::future::Future<Output = Result<FeeRate>> + Send;
}
/// A subscription to the status of a given transaction
/// that can be used to wait for the transaction to be confirmed.
#[derive(Debug, Clone)]
pub struct Subscription {
/// A receiver used to await updates to the status of the transaction.
pub receiver: tokio::sync::watch::Receiver<ScriptStatus>,
/// The number of confirmations we require for a transaction to be considered final.
pub finality_confirmations: u32,
/// The transaction ID we are subscribing to.
pub txid: Txid,
}
impl Subscription {
pub fn new(
receiver: tokio::sync::watch::Receiver<ScriptStatus>,
finality_confirmations: u32,
txid: Txid,
) -> Self {
Self {
receiver,
finality_confirmations,
txid,
}
}
pub async fn wait_until_final(&self) -> anyhow::Result<()> {
let conf_target = self.finality_confirmations;
let txid = self.txid;
tracing::info!(%txid, required_confirmation=%conf_target, "Waiting for Bitcoin transaction finality");
let mut seen_confirmations = 0;
self.wait_until(|status| match status {
ScriptStatus::Confirmed(inner) => {
let confirmations = inner.confirmations();
if confirmations > seen_confirmations {
tracing::info!(%txid,
seen_confirmations = %confirmations,
needed_confirmations = %conf_target,
"Waiting for Bitcoin transaction finality");
seen_confirmations = confirmations;
}
inner.meets_target(conf_target)
}
_ => false,
})
.await
}
pub async fn wait_until_seen(&self) -> anyhow::Result<()> {
self.wait_until(ScriptStatus::has_been_seen).await
}
pub async fn wait_until_confirmed_with<T>(&self, target: T) -> anyhow::Result<()>
where
T: Into<u32>,
T: Copy,
{
self.wait_until(|status| status.is_confirmed_with(target))
.await
}
pub async fn wait_until(
&self,
mut predicate: impl FnMut(&ScriptStatus) -> bool,
) -> anyhow::Result<()> {
let mut receiver = self.receiver.clone();
while !predicate(&receiver.borrow()) {
receiver
.changed()
.await
.map_err(|_| anyhow::anyhow!("Failed while waiting for next status update"))?;
}
Ok(())
}
}
/// The possible statuses of a script.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ScriptStatus {
Unseen,
InMempool,
Confirmed(Confirmed),
Retrying,
}
/// The status of a confirmed transaction.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Confirmed {
/// The depth of this transaction within the blockchain.
///
/// Zero if the transaction is included in the latest block.
pub depth: u32,
}
impl Confirmed {
pub fn new(depth: u32) -> Self {
Self { depth }
}
/// Compute the depth of a transaction based on its inclusion height and the
/// latest known block.
///
/// Our information about the latest block might be outdated. To avoid an
/// overflow, we make sure the depth is 0 in case the inclusion height
/// exceeds our latest known block,
pub fn from_inclusion_and_latest_block(inclusion_height: u32, latest_block: u32) -> Self {
let depth = latest_block.saturating_sub(inclusion_height);
Self { depth }
}
pub fn confirmations(&self) -> u32 {
self.depth + 1
}
pub fn meets_target<T>(&self, target: T) -> bool
where
T: Into<u32>,
{
self.confirmations() >= target.into()
}
pub fn blocks_left_until<T>(&self, target: T) -> u32
where
T: Into<u32> + Copy,
{
if self.meets_target(target) {
0
} else {
target.into() - self.confirmations()
}
}
}
impl std::fmt::Display for ScriptStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScriptStatus::Unseen => write!(f, "unseen"),
ScriptStatus::InMempool => write!(f, "in mempool"),
ScriptStatus::Retrying => write!(f, "retrying"),
ScriptStatus::Confirmed(inner) => {
write!(f, "confirmed with {} blocks", inner.confirmations())
}
}
}
}
/// Defines a watchable transaction.
///
/// For a transaction to be watchable, we need to know two things: Its
/// transaction ID and the specific output script that is going to change.
/// A transaction can obviously have multiple outputs but our protocol purposes,
/// we are usually interested in a specific one.
pub trait Watchable: Send + Sync {
/// The transaction ID.
fn id(&self) -> Txid;
/// The script of the output we are interested in.
fn script(&self) -> ScriptBuf;
/// Convenience method to get both the script and the txid.
fn script_and_txid(&self) -> (ScriptBuf, Txid) {
(self.script(), self.id())
}
}
impl Watchable for (Txid, ScriptBuf) {
fn id(&self) -> Txid {
self.0
}
fn script(&self) -> ScriptBuf {
self.1.clone()
}
}
impl Watchable for &dyn Watchable {
fn id(&self) -> Txid {
(*self).id()
}
fn script(&self) -> ScriptBuf {
(*self).script()
}
}
impl Watchable for Box<dyn Watchable> {
fn id(&self) -> Txid {
(**self).id()
}
fn script(&self) -> ScriptBuf {
(**self).script()
}
}
impl ScriptStatus {
pub fn from_confirmations(confirmations: u32) -> Self {
match confirmations {
0 => Self::InMempool,
confirmations => Self::Confirmed(Confirmed::new(confirmations - 1)),
}
}
}
impl ScriptStatus {
/// Check if the script has any confirmations.
pub fn is_confirmed(&self) -> bool {
matches!(self, ScriptStatus::Confirmed(_))
}
/// Check if the script has met the given confirmation target.
pub fn is_confirmed_with<T>(&self, target: T) -> bool
where
T: Into<u32>,
{
match self {
ScriptStatus::Confirmed(inner) => inner.meets_target(target),
_ => false,
}
}
// Calculate the number of blocks left until the target is met.
pub fn blocks_left_until<T>(&self, target: T) -> u32
where
T: Into<u32> + Copy,
{
match self {
ScriptStatus::Confirmed(inner) => inner.blocks_left_until(target),
_ => target.into(),
}
}
pub fn has_been_seen(&self) -> bool {
matches!(self, ScriptStatus::InMempool | ScriptStatus::Confirmed(_))
}
}

View file

@ -105,6 +105,9 @@ check_gui_eslint:
check_gui_tsc:
cd src-gui && yarn run tsc --noEmit
test test_name:
cargo test --test {{test_name}} -- --nocapture
# Run the checks for the GUI frontend
check_gui:
just check_gui_eslint || true

View file

@ -1,4 +1,4 @@
use arti_client::{config::StreamTimeoutConfig, TorClient, TorClientConfig};
use arti_client::{TorClient, TorClientConfig};
use clap::Parser;
use cuprate_epee_encoding::{epee_object, from_bytes, to_bytes};
use futures::stream::{self, StreamExt};

View file

@ -16,7 +16,6 @@ use tokio::{
};
use tokio_rustls::rustls::{
self,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
pki_types::{CertificateDer, ServerName, UnixTime},
DigitallySignedStruct, Error as TlsError, SignatureScheme,

View file

@ -3,7 +3,7 @@ use zeroize::Zeroizing;
use curve25519_dalek::scalar::Scalar;
use monero_primitives::keccak256;
use monero_oxide::primitives::keccak256;
use crate::*;

View file

@ -177,6 +177,14 @@ impl TryFrom<String> for Daemon {
}
}
impl<'a> TryFrom<&'a str> for Daemon {
type Error = anyhow::Error;
fn try_from(address: &'a str) -> Result<Self, Self::Error> {
address.to_string().try_into()
}
}
impl Daemon {
/// Try to convert the daemon configuration to a URL
pub fn to_url_string(&self) -> String {

View file

@ -10,10 +10,7 @@ async fn test_sign_message() {
.init();
let temp_dir = tempfile::tempdir().unwrap();
let daemon = Daemon {
address: PLACEHOLDER_NODE.into(),
ssl: false,
};
let daemon = Daemon::try_from(PLACEHOLDER_NODE).unwrap();
let wallet_name = "test_signing_wallet";
let wallet_path = temp_dir.path().join(wallet_name).display().to_string();

View file

@ -15,10 +15,7 @@ async fn main() {
.init();
let temp_dir = tempfile::tempdir().unwrap();
let daemon = Daemon {
address: STAGENET_REMOTE_NODE.into(),
ssl: true,
};
let daemon = Daemon::try_from(STAGENET_REMOTE_NODE).unwrap();
let wallet_name = "recovered_wallet";
let wallet_path = temp_dir.path().join(wallet_name).display().to_string();

View file

@ -13,10 +13,7 @@ async fn test_wallet_with_special_paths() {
"path-with-hyphen",
];
let daemon = Daemon {
address: "https://moneronode.org:18081".into(),
ssl: true,
};
let daemon = Daemon::try_from("https://moneronode.org:18081").unwrap();
let futures = special_paths
.into_iter()

View file

@ -10,10 +10,7 @@ async fn main() {
.init();
let temp_dir = tempfile::tempdir().unwrap();
let daemon = Daemon {
address: STAGENET_REMOTE_NODE.into(),
ssl: true,
};
let daemon = Daemon::try_from(STAGENET_REMOTE_NODE).unwrap();
{
let wallet = WalletHandle::open_or_create(

View file

@ -25,6 +25,7 @@ swap = { path = "../swap" }
swap-env = { path = "../swap-env" }
swap-feed = { path = "../swap-feed" }
swap-serde = { path = "../swap-serde" }
swap-machine = { path = "../swap-machine"}
# Async
tokio = { workspace = true, features = ["process", "fs", "net", "parking_lot", "rt"] }

View file

@ -34,7 +34,6 @@ use swap::common::{self, get_logs, warn_if_outdated};
use swap::database::{open_db, AccessMode};
use swap::network::rendezvous::XmrBtcNamespace;
use swap::network::swarm;
use swap::protocol::alice::swap::is_complete;
use swap::protocol::alice::{run, AliceState, TipConfig};
use swap::protocol::{Database, State};
use swap::seed::Seed;
@ -43,6 +42,7 @@ use swap_env::config::{
initial_setup, query_user_for_initial_config, read_config, Config, ConfigNotInitialized,
};
use swap_feed;
use swap_machine::alice::is_complete;
use tracing_subscriber::filter::LevelFilter;
use uuid::Uuid;

View file

@ -7,10 +7,6 @@ edition = "2021"
bitcoin = { workspace = true }
jsonrpsee = { workspace = true, features = ["macros", "server", "client-core", "http-client"] }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
[lints]
workspace = true

View file

@ -13,7 +13,6 @@ clap = { version = "4", features = ["derive"] }
jsonrpsee = { workspace = true, features = ["client-core", "http-client"] }
monero = { workspace = true }
rustyline = "17.0.0"
serde_json = { workspace = true }
shell-words = "1.1"
swap-controller-api = { path = "../swap-controller-api" }
tokio = { workspace = true }

44
swap-core/Cargo.toml Normal file
View file

@ -0,0 +1,44 @@
[package]
name = "swap-core"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
# Bitcoin stuff
bdk_electrum = { workspace = true }
bdk_wallet = { workspace = true, features = ["rusqlite"] }
bitcoin = { workspace = true }
bitcoin-wallet = { path = "../bitcoin-wallet" }
curve25519-dalek = { workspace = true }
ecdsa_fun = { workspace = true }
electrum-pool = { path = "../electrum-pool" }
monero = { workspace = true }
sha2 = { workspace = true }
# Serialization
rust_decimal = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
swap-serde = { path = "../swap-serde" }
typeshare = { workspace = true }
# Tracing
tracing = { workspace = true }
# Errors
thiserror = { workspace = true }
# Randomness
rand = { workspace = true }
rand_chacha = { workspace = true }
[dev-dependencies]
serde_cbor = { workspace = true }
swap-env = { path = "../swap-env" }
tokio = { workspace = true }
uuid = { workspace = true }
[lints]
workspace = true

801
swap-core/src/bitcoin.rs Normal file
View file

@ -0,0 +1,801 @@
mod cancel;
mod early_refund;
mod lock;
mod punish;
mod redeem;
mod refund;
mod timelocks;
pub use crate::bitcoin::cancel::{CancelTimelock, PunishTimelock, TxCancel};
pub use crate::bitcoin::early_refund::TxEarlyRefund;
pub use crate::bitcoin::lock::TxLock;
pub use crate::bitcoin::punish::TxPunish;
pub use crate::bitcoin::redeem::TxRedeem;
pub use crate::bitcoin::refund::TxRefund;
pub use crate::bitcoin::timelocks::{BlockHeight, ExpiredTimelocks};
pub use ::bitcoin::amount::Amount;
pub use ::bitcoin::psbt::Psbt as PartiallySignedTransaction;
pub use ::bitcoin::{Address, AddressType, Network, Transaction, Txid};
pub use ecdsa_fun::Signature;
pub use ecdsa_fun::adaptor::EncryptedSignature;
pub use ecdsa_fun::fun::Scalar;
use ::bitcoin::hashes::Hash;
use ::bitcoin::secp256k1::ecdsa;
use ::bitcoin::sighash::SegwitV0Sighash as Sighash;
use anyhow::{Context, Result, bail};
use bdk_wallet::miniscript::descriptor::Wsh;
use bdk_wallet::miniscript::{Descriptor, Segwitv0};
use bitcoin_wallet::primitives::ScriptStatus;
use ecdsa_fun::ECDSA;
use ecdsa_fun::adaptor::{Adaptor, HashTranscript};
use ecdsa_fun::fun::Point;
use ecdsa_fun::nonce::Deterministic;
use rand::{CryptoRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::str::FromStr;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct SecretKey {
inner: Scalar,
public: Point,
}
impl SecretKey {
pub fn new_random<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
let scalar = Scalar::random(rng);
let ecdsa = ECDSA::<()>::default();
let public = ecdsa.verification_key_for(&scalar);
Self {
inner: scalar,
public,
}
}
pub fn public(&self) -> PublicKey {
PublicKey(self.public)
}
pub fn to_bytes(&self) -> [u8; 32] {
self.inner.to_bytes()
}
pub fn sign(&self, digest: Sighash) -> Signature {
let ecdsa = ECDSA::<Deterministic<Sha256>>::default();
ecdsa.sign(&self.inner, &digest.to_byte_array())
}
// TxRefund encsigning explanation:
//
// A and B, are the Bitcoin Public Keys which go on the joint output for
// TxLock_Bitcoin. S_a and S_b, are the Monero Public Keys which go on the
// joint output for TxLock_Monero
// tx_refund: multisig(A, B), published by bob
// bob can produce sig on B using b
// alice sends over an encrypted signature on A encrypted with S_b
// s_b is leaked to alice when bob publishes signed tx_refund allowing her to
// recover s_b: recover(encsig, S_b, sig_tx_refund) = s_b
// alice now has s_a and s_b and can refund monero
// self = a, Y = S_b, digest = tx_refund
pub fn encsign(&self, Y: PublicKey, digest: Sighash) -> EncryptedSignature {
let adaptor = Adaptor::<
HashTranscript<Sha256, rand_chacha::ChaCha20Rng>,
Deterministic<Sha256>,
>::default();
adaptor.encrypted_sign(&self.inner, &Y.0, &digest.to_byte_array())
}
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublicKey(Point);
impl PublicKey {
#[cfg(test)]
pub fn random() -> Self {
Self(Point::random(&mut rand::thread_rng()))
}
}
impl From<PublicKey> for Point {
fn from(from: PublicKey) -> Self {
from.0
}
}
impl TryFrom<PublicKey> for bitcoin::PublicKey {
type Error = bitcoin::key::FromSliceError;
fn try_from(pubkey: PublicKey) -> Result<Self, Self::Error> {
let bytes = pubkey.0.to_bytes();
bitcoin::PublicKey::from_slice(&bytes)
}
}
impl TryFrom<bitcoin::PublicKey> for PublicKey {
type Error = anyhow::Error;
fn try_from(pubkey: bitcoin::PublicKey) -> Result<Self, Self::Error> {
let bytes = pubkey.to_bytes();
let bytes_array: [u8; 33] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid public key length"))?;
let point = Point::from_bytes(bytes_array)
.ok_or_else(|| anyhow::anyhow!("Invalid public key bytes"))?;
Ok(PublicKey(point))
}
}
impl From<Point> for PublicKey {
fn from(p: Point) -> Self {
Self(p)
}
}
impl From<Scalar> for SecretKey {
fn from(scalar: Scalar) -> Self {
let ecdsa = ECDSA::<()>::default();
let public = ecdsa.verification_key_for(&scalar);
Self {
inner: scalar,
public,
}
}
}
impl From<SecretKey> for Scalar {
fn from(sk: SecretKey) -> Self {
sk.inner
}
}
impl From<Scalar> for PublicKey {
fn from(scalar: Scalar) -> Self {
let ecdsa = ECDSA::<()>::default();
PublicKey(ecdsa.verification_key_for(&scalar))
}
}
pub fn verify_sig(
verification_key: &PublicKey,
transaction_sighash: &Sighash,
sig: &Signature,
) -> Result<()> {
let ecdsa = ECDSA::verify_only();
if ecdsa.verify(
&verification_key.0,
&transaction_sighash.to_byte_array(),
sig,
) {
Ok(())
} else {
bail!(InvalidSignature)
}
}
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[error("signature is invalid")]
pub struct InvalidSignature;
pub fn verify_encsig(
verification_key: PublicKey,
encryption_key: PublicKey,
digest: &Sighash,
encsig: &EncryptedSignature,
) -> Result<()> {
let adaptor = Adaptor::<HashTranscript<Sha256>, Deterministic<Sha256>>::default();
if adaptor.verify_encrypted_signature(
&verification_key.0,
&encryption_key.0,
&digest.to_byte_array(),
encsig,
) {
Ok(())
} else {
bail!(InvalidEncryptedSignature)
}
}
#[derive(Clone, Copy, Debug, thiserror::Error)]
#[error("encrypted signature is invalid")]
pub struct InvalidEncryptedSignature;
pub fn build_shared_output_descriptor(
A: Point,
B: Point,
) -> Result<Descriptor<bitcoin::PublicKey>> {
const MINISCRIPT_TEMPLATE: &str = "c:and_v(v:pk(A),pk_k(B))";
let miniscript = MINISCRIPT_TEMPLATE
.replace('A', &A.to_string())
.replace('B', &B.to_string());
let miniscript =
bdk_wallet::miniscript::Miniscript::<bitcoin::PublicKey, Segwitv0>::from_str(&miniscript)
.expect("a valid miniscript");
Ok(Descriptor::Wsh(Wsh::new(miniscript)?))
}
pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Result<SecretKey> {
let adaptor = Adaptor::<HashTranscript<Sha256>, Deterministic<Sha256>>::default();
let s = adaptor
.recover_decryption_key(&S.0, &sig, &encsig)
.map(SecretKey::from)
.context("Failed to recover secret from adaptor signature")?;
Ok(s)
}
pub fn current_epoch(
cancel_timelock: CancelTimelock,
punish_timelock: PunishTimelock,
tx_lock_status: ScriptStatus,
tx_cancel_status: ScriptStatus,
) -> ExpiredTimelocks {
if tx_cancel_status.is_confirmed_with(punish_timelock) {
return ExpiredTimelocks::Punish;
}
if tx_lock_status.is_confirmed_with(cancel_timelock) {
return ExpiredTimelocks::Cancel {
blocks_left: tx_cancel_status.blocks_left_until(punish_timelock),
};
}
ExpiredTimelocks::None {
blocks_left: tx_lock_status.blocks_left_until(cancel_timelock),
}
}
pub mod bitcoin_address {
use anyhow::{Context, Result};
use bitcoin::{
Address,
address::{NetworkChecked, NetworkUnchecked},
};
use serde::Serialize;
use std::str::FromStr;
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Serialize)]
#[error(
"Invalid Bitcoin address provided, expected address on network {expected:?} but address provided is on {actual:?}"
)]
pub struct BitcoinAddressNetworkMismatch {
#[serde(with = "swap_serde::bitcoin::network")]
expected: bitcoin::Network,
#[serde(with = "swap_serde::bitcoin::network")]
actual: bitcoin::Network,
}
pub fn parse(addr_str: &str) -> Result<bitcoin::Address<NetworkUnchecked>> {
let address = bitcoin::Address::from_str(addr_str)?;
if address.assume_checked_ref().address_type() != Some(bitcoin::AddressType::P2wpkh) {
anyhow::bail!("Invalid Bitcoin address provided, only bech32 format is supported!")
}
Ok(address)
}
/// Parse the address and validate the network.
pub fn parse_and_validate_network(
address: &str,
expected_network: bitcoin::Network,
) -> Result<bitcoin::Address> {
let addres = bitcoin::Address::from_str(address)?;
let addres = addres.require_network(expected_network).with_context(|| {
format!("Bitcoin address network mismatch, expected `{expected_network:?}`")
})?;
Ok(addres)
}
/// Parse the address and validate the network.
pub fn parse_and_validate(address: &str, is_testnet: bool) -> Result<bitcoin::Address> {
let expected_network = if is_testnet {
bitcoin::Network::Testnet
} else {
bitcoin::Network::Bitcoin
};
parse_and_validate_network(address, expected_network)
}
/// Validate the address network.
pub fn validate(
address: Address<NetworkUnchecked>,
is_testnet: bool,
) -> Result<Address<NetworkChecked>> {
let expected_network = if is_testnet {
bitcoin::Network::Testnet
} else {
bitcoin::Network::Bitcoin
};
validate_network(address, expected_network)
}
/// Validate the address network.
pub fn validate_network(
address: Address<NetworkUnchecked>,
expected_network: bitcoin::Network,
) -> Result<Address<NetworkChecked>> {
address
.require_network(expected_network)
.context("Bitcoin address network mismatch")
}
/// Validate the address network even though the address is already checked.
pub fn revalidate_network(
address: Address,
expected_network: bitcoin::Network,
) -> Result<Address> {
address
.as_unchecked()
.clone()
.require_network(expected_network)
.context("bitcoin address network mismatch")
}
/// Validate the address network even though the address is already checked.
pub fn revalidate(address: Address, is_testnet: bool) -> Result<Address> {
revalidate_network(
address,
if is_testnet {
bitcoin::Network::Testnet
} else {
bitcoin::Network::Bitcoin
},
)
}
}
// Transform the ecdsa der signature bytes into a secp256kfun ecdsa signature type.
pub fn extract_ecdsa_sig(sig: &[u8]) -> Result<Signature> {
let data = &sig[..sig.len() - 1];
let sig = ecdsa::Signature::from_der(data)?.serialize_compact();
Signature::from_bytes(sig).ok_or(anyhow::anyhow!("invalid signature"))
}
/// Bitcoin error codes: https://github.com/bitcoin/bitcoin/blob/97d3500601c1d28642347d014a6de1e38f53ae4e/src/rpc/protocol.h#L23
pub enum RpcErrorCode {
/// Transaction or block was rejected by network rules. Error code -26.
RpcVerifyRejected,
/// Transaction or block was rejected by network rules. Error code -27.
RpcVerifyAlreadyInChain,
/// General error during transaction or block submission
RpcVerifyError,
/// Invalid address or key. Error code -5. Is throwns when a transaction is not found.
/// See:
/// - https://github.com/bitcoin/bitcoin/blob/ae024137bda9fe189f4e7ccf26dbaffd44cbbeb6/src/rpc/mempool.cpp#L470-L472
/// - https://github.com/bitcoin/bitcoin/blob/ae024137bda9fe189f4e7ccf26dbaffd44cbbeb6/src/rpc/rawtransaction.cpp#L352-L368
RpcInvalidAddressOrKey,
}
impl From<RpcErrorCode> for i64 {
fn from(code: RpcErrorCode) -> Self {
match code {
RpcErrorCode::RpcVerifyError => -25,
RpcErrorCode::RpcVerifyRejected => -26,
RpcErrorCode::RpcVerifyAlreadyInChain => -27,
RpcErrorCode::RpcInvalidAddressOrKey => -5,
}
}
}
pub fn parse_rpc_error_code(error: &anyhow::Error) -> anyhow::Result<i64> {
// First try to extract an Electrum error from a MultiError if present
if let Some(multi_error) = error.downcast_ref::<electrum_pool::MultiError>() {
// Try to find the first Electrum error in the MultiError
for single_error in multi_error.iter() {
if let bdk_electrum::electrum_client::Error::Protocol(serde_json::Value::String(
string,
)) = single_error
{
let json = serde_json::from_str(
&string
.replace("sendrawtransaction RPC error:", "")
.replace("daemon error:", ""),
)?;
let json_map = match json {
serde_json::Value::Object(map) => map,
_ => continue, // Try next error if this one isn't a JSON object
};
let error_code_value = match json_map.get("code") {
Some(val) => val,
None => continue, // Try next error if no error code field
};
let error_code_number = match error_code_value {
serde_json::Value::Number(num) => num,
_ => continue, // Try next error if error code isn't a number
};
if let Some(int) = error_code_number.as_i64() {
return Ok(int);
}
}
}
// If we couldn't extract an RPC error code from any error in the MultiError
bail!(
"Error is of incorrect variant. We expected an Electrum error, but got: {}",
error
);
}
// Original logic for direct Electrum errors
let string = match error.downcast_ref::<bdk_electrum::electrum_client::Error>() {
Some(bdk_electrum::electrum_client::Error::Protocol(serde_json::Value::String(string))) => {
string
}
_ => bail!(
"Error is of incorrect variant. We expected an Electrum error, but got: {}",
error
),
};
let json = serde_json::from_str(
&string
.replace("sendrawtransaction RPC error:", "")
.replace("daemon error:", ""),
)?;
let json_map = match json {
serde_json::Value::Object(map) => map,
_ => bail!("Json error is not json object "),
};
let error_code_value = match json_map.get("code") {
Some(val) => val,
None => bail!("No error code field"),
};
let error_code_number = match error_code_value {
serde_json::Value::Number(num) => num,
_ => bail!("Error code is not a number"),
};
if let Some(int) = error_code_number.as_i64() {
Ok(int)
} else {
bail!("Error code is not an unsigned integer")
}
}
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("transaction does not spend anything")]
pub struct NoInputs;
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("transaction has {0} inputs, expected 1")]
pub struct TooManyInputs(usize);
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("empty witness stack")]
pub struct EmptyWitnessStack;
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("input has {0} witnesses, expected 3")]
pub struct NotThreeWitnesses(usize);
#[cfg(test)]
pub use crate::bitcoin::wallet::TestWalletBuilder;
#[cfg(test)]
mod tests {
use super::*;
use crate::monero::TransferProof;
use crate::protocol::{alice, bob};
use bitcoin::secp256k1;
use curve25519_dalek::scalar::Scalar;
use ecdsa_fun::fun::marker::{NonZero, Public};
use monero::PrivateKey;
use rand::rngs::OsRng;
use std::matches;
use swap_env::env::{GetConfig, Regtest};
use uuid::Uuid;
#[test]
fn lock_confirmations_le_to_cancel_timelock_no_timelock_expired() {
let tx_lock_status = ScriptStatus::from_confirmations(4);
let tx_cancel_status = ScriptStatus::Unseen;
let expired_timelock = current_epoch(
CancelTimelock::new(5),
PunishTimelock::new(5),
tx_lock_status,
tx_cancel_status,
);
assert!(matches!(expired_timelock, ExpiredTimelocks::None { .. }));
}
#[test]
fn lock_confirmations_ge_to_cancel_timelock_cancel_timelock_expired() {
let tx_lock_status = ScriptStatus::from_confirmations(5);
let tx_cancel_status = ScriptStatus::Unseen;
let expired_timelock = current_epoch(
CancelTimelock::new(5),
PunishTimelock::new(5),
tx_lock_status,
tx_cancel_status,
);
assert!(matches!(expired_timelock, ExpiredTimelocks::Cancel { .. }));
}
#[test]
fn cancel_confirmations_ge_to_punish_timelock_punish_timelock_expired() {
let tx_lock_status = ScriptStatus::from_confirmations(10);
let tx_cancel_status = ScriptStatus::from_confirmations(5);
let expired_timelock = current_epoch(
CancelTimelock::new(5),
PunishTimelock::new(5),
tx_lock_status,
tx_cancel_status,
);
assert_eq!(expired_timelock, ExpiredTimelocks::Punish)
}
#[tokio::test]
async fn calculate_transaction_weights() {
let alice_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let bob_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let spending_fee = Amount::from_sat(1_000);
let btc_amount = Amount::from_sat(500_000);
let xmr_amount = crate::monero::Amount::from_piconero(10000);
let tx_redeem_fee = alice_wallet
.estimate_fee(TxRedeem::weight(), Some(btc_amount))
.await
.unwrap();
let tx_punish_fee = alice_wallet
.estimate_fee(TxPunish::weight(), Some(btc_amount))
.await
.unwrap();
let tx_lock_fee = alice_wallet
.estimate_fee(TxLock::weight(), Some(btc_amount))
.await
.unwrap();
let redeem_address = alice_wallet.new_address().await.unwrap();
let punish_address = alice_wallet.new_address().await.unwrap();
let config = Regtest::get_config();
let alice_state0 = alice::State0::new(
btc_amount,
xmr_amount,
config,
redeem_address,
punish_address,
tx_redeem_fee,
tx_punish_fee,
&mut OsRng,
);
let bob_state0 = bob::State0::new(
Uuid::new_v4(),
&mut OsRng,
btc_amount,
xmr_amount,
config.bitcoin_cancel_timelock,
config.bitcoin_punish_timelock,
bob_wallet.new_address().await.unwrap(),
config.monero_finality_confirmations,
spending_fee,
spending_fee,
tx_lock_fee,
);
let message0 = bob_state0.next_message();
let (_, alice_state1) = alice_state0.receive(message0).unwrap();
let alice_message1 = alice_state1.next_message();
let bob_state1 = bob_state0
.receive(&bob_wallet, alice_message1)
.await
.unwrap();
let bob_message2 = bob_state1.next_message();
let alice_state2 = alice_state1.receive(bob_message2).unwrap();
let alice_message3 = alice_state2.next_message();
let bob_state2 = bob_state1.receive(alice_message3).unwrap();
let bob_message4 = bob_state2.next_message();
let alice_state3 = alice_state2.receive(bob_message4).unwrap();
let (bob_state3, _tx_lock) = bob_state2.lock_btc().await.unwrap();
let bob_state4 = bob_state3.xmr_locked(
crate::monero::BlockHeight { height: 0 },
// We use bogus values here, because they're irrelevant to this test
TransferProof::new(
crate::monero::TxHash("foo".into()),
PrivateKey::from_scalar(Scalar::one()),
),
);
let encrypted_signature = bob_state4.tx_redeem_encsig();
let bob_state6 = bob_state4.cancel();
let cancel_transaction = alice_state3.signed_cancel_transaction().unwrap();
let punish_transaction = alice_state3.signed_punish_transaction().unwrap();
let redeem_transaction = alice_state3
.signed_redeem_transaction(encrypted_signature)
.unwrap();
let refund_transaction = bob_state6.signed_refund_transaction().unwrap();
assert_weight(redeem_transaction, TxRedeem::weight().to_wu(), "TxRedeem");
assert_weight(cancel_transaction, TxCancel::weight().to_wu(), "TxCancel");
assert_weight(punish_transaction, TxPunish::weight().to_wu(), "TxPunish");
assert_weight(refund_transaction, TxRefund::weight().to_wu(), "TxRefund");
// Test TxEarlyRefund transaction
let early_refund_transaction = alice_state3
.signed_early_refund_transaction()
.unwrap()
.unwrap();
assert_weight(
early_refund_transaction,
TxEarlyRefund::weight() as u64,
"TxEarlyRefund",
);
}
#[tokio::test]
async fn tx_early_refund_can_be_constructed_and_signed() {
let alice_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let bob_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let spending_fee = Amount::from_sat(1_000);
let btc_amount = Amount::from_sat(500_000);
let xmr_amount = crate::monero::Amount::from_piconero(10000);
let tx_redeem_fee = alice_wallet
.estimate_fee(TxRedeem::weight(), Some(btc_amount))
.await
.unwrap();
let tx_punish_fee = alice_wallet
.estimate_fee(TxPunish::weight(), Some(btc_amount))
.await
.unwrap();
let refund_address = alice_wallet.new_address().await.unwrap();
let punish_address = alice_wallet.new_address().await.unwrap();
let config = Regtest::get_config();
let alice_state0 = alice::State0::new(
btc_amount,
xmr_amount,
config,
refund_address.clone(),
punish_address,
tx_redeem_fee,
tx_punish_fee,
&mut OsRng,
);
let bob_state0 = bob::State0::new(
Uuid::new_v4(),
&mut OsRng,
btc_amount,
xmr_amount,
config.bitcoin_cancel_timelock,
config.bitcoin_punish_timelock,
bob_wallet.new_address().await.unwrap(),
config.monero_finality_confirmations,
spending_fee,
spending_fee,
spending_fee,
);
// Complete the state machine up to State3
let message0 = bob_state0.next_message();
let (_, alice_state1) = alice_state0.receive(message0).unwrap();
let alice_message1 = alice_state1.next_message();
let bob_state1 = bob_state0
.receive(&bob_wallet, alice_message1)
.await
.unwrap();
let bob_message2 = bob_state1.next_message();
let alice_state2 = alice_state1.receive(bob_message2).unwrap();
let alice_message3 = alice_state2.next_message();
let bob_state2 = bob_state1.receive(alice_message3).unwrap();
let bob_message4 = bob_state2.next_message();
let alice_state3 = alice_state2.receive(bob_message4).unwrap();
// Test TxEarlyRefund construction
let tx_early_refund = alice_state3.tx_early_refund();
// Verify basic properties
assert_eq!(tx_early_refund.txid(), tx_early_refund.txid()); // Should be deterministic
assert!(tx_early_refund.digest() != Sighash::all_zeros()); // Should have valid digest
// Test that it can be signed and completed
let early_refund_transaction = alice_state3
.signed_early_refund_transaction()
.unwrap()
.unwrap();
// Verify the transaction has expected structure
assert_eq!(early_refund_transaction.input.len(), 1); // One input from lock tx
assert_eq!(early_refund_transaction.output.len(), 1); // One output to refund address
assert_eq!(
early_refund_transaction.output[0].script_pubkey,
refund_address.script_pubkey()
);
// Verify the input is spending the lock transaction
assert_eq!(
early_refund_transaction.input[0].previous_output,
alice_state3.tx_lock.as_outpoint()
);
// Verify the amount is correct (lock amount minus fee)
let expected_amount = alice_state3.tx_lock.lock_amount() - alice_state3.tx_refund_fee;
assert_eq!(early_refund_transaction.output[0].value, expected_amount);
}
#[test]
fn tx_early_refund_has_correct_weight() {
// TxEarlyRefund should have the same weight as other similar transactions
assert_eq!(TxEarlyRefund::weight(), 548);
// It should be the same as TxRedeem and TxRefund weights since they have similar structure
assert_eq!(TxEarlyRefund::weight() as u64, TxRedeem::weight().to_wu());
assert_eq!(TxEarlyRefund::weight() as u64, TxRefund::weight().to_wu());
}
// Weights fluctuate because of the length of the signatures. Valid ecdsa
// signatures can have 68, 69, 70, 71, or 72 bytes. Since most of our
// transactions have 2 signatures the weight can be up to 8 bytes less than
// the static weight (4 bytes per signature).
fn assert_weight(transaction: Transaction, expected_weight: u64, tx_name: &str) {
let is_weight = transaction.weight();
assert!(
expected_weight - is_weight.to_wu() <= 8,
"{} to have weight {}, but was {}. Transaction: {:#?}",
tx_name,
expected_weight,
is_weight,
transaction
)
}
#[test]
fn compare_point_hex() {
// secp256kfun Point and secp256k1 PublicKey should have the same bytes and hex representation
let secp = secp256k1::Secp256k1::default();
let keypair = secp256k1::Keypair::new(&secp, &mut OsRng);
let pubkey = keypair.public_key();
let point: Point<_, Public, NonZero> = Point::from_bytes(pubkey.serialize()).unwrap();
assert_eq!(pubkey.to_string(), point.to_string());
}
}

View file

@ -1,17 +1,17 @@
use crate::bitcoin;
use crate::bitcoin::wallet::Watchable;
use crate::bitcoin::{
build_shared_output_descriptor, Address, Amount, BlockHeight, PublicKey, Transaction, TxLock,
Address, Amount, BlockHeight, PublicKey, Transaction, TxLock, build_shared_output_descriptor,
};
use ::bitcoin::Weight;
use ::bitcoin::sighash::SighashCache;
use ::bitcoin::transaction::Version;
use ::bitcoin::Weight;
use ::bitcoin::{
locktime::absolute::LockTime as PackedLockTime, secp256k1, sighash::SegwitV0Sighash as Sighash,
EcdsaSighashType, OutPoint, ScriptBuf, Sequence, TxIn, TxOut, Txid,
locktime::absolute::LockTime as PackedLockTime, secp256k1, sighash::SegwitV0Sighash as Sighash,
};
use anyhow::Result;
use bdk_wallet::miniscript::Descriptor;
use bitcoin_wallet::primitives::Watchable;
use ecdsa_fun::Signature;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;

View file

@ -1,14 +1,14 @@
use crate::bitcoin;
use ::bitcoin::sighash::SighashCache;
use ::bitcoin::{secp256k1, ScriptBuf};
use ::bitcoin::{sighash::SegwitV0Sighash as Sighash, EcdsaSighashType, Txid};
use ::bitcoin::{EcdsaSighashType, Txid, sighash::SegwitV0Sighash as Sighash};
use ::bitcoin::{ScriptBuf, secp256k1};
use anyhow::{Context, Result};
use bdk_wallet::miniscript::Descriptor;
use bitcoin::{Address, Amount, Transaction};
use std::collections::HashMap;
use super::wallet::Watchable;
use super::TxLock;
use bitcoin_wallet::primitives::Watchable;
const TX_EARLY_REFUND_WEIGHT: usize = 548;

View file

@ -1,17 +1,13 @@
use crate::bitcoin::wallet::Watchable;
use crate::bitcoin::{
build_shared_output_descriptor, Address, Amount, PublicKey, Transaction, Wallet,
};
use crate::bitcoin::{Address, Amount, PublicKey, Transaction, build_shared_output_descriptor};
use ::bitcoin::psbt::Psbt as PartiallySignedTransaction;
use ::bitcoin::{OutPoint, TxIn, TxOut, Txid};
use anyhow::{bail, Context, Result};
use anyhow::{Context, Result, bail};
use bdk_wallet::miniscript::Descriptor;
use bdk_wallet::psbt::PsbtUtils;
use bitcoin::{locktime::absolute::LockTime as PackedLockTime, ScriptBuf, Sequence};
use bitcoin::{ScriptBuf, Sequence, locktime::absolute::LockTime as PackedLockTime};
use bitcoin_wallet::primitives::Watchable;
use serde::{Deserialize, Serialize};
use super::wallet::EstimateFeeRate;
const SCRIPT_SIZE: usize = 34;
const TX_LOCK_WEIGHT: usize = 485;
@ -23,10 +19,7 @@ pub struct TxLock {
impl TxLock {
pub async fn new(
wallet: &Wallet<
bdk_wallet::rusqlite::Connection,
impl EstimateFeeRate + Send + Sync + 'static,
>,
wallet: &dyn bitcoin_wallet::BitcoinWallet,
amount: Amount,
spending_fee: Amount,
A: PublicKey,
@ -202,8 +195,8 @@ impl Watchable for TxLock {
#[cfg(test)]
mod tests {
use super::*;
use crate::bitcoin::wallet::TestWalletBuilder;
use crate::bitcoin::Amount;
use crate::bitcoin::wallet::TestWalletBuilder;
use ::bitcoin::psbt::Psbt as PartiallySignedTransaction;
// Basic setup function for tests
@ -286,10 +279,7 @@ mod tests {
async fn bob_make_psbt(
A: PublicKey,
B: PublicKey,
wallet: &Wallet<
bdk_wallet::rusqlite::Connection,
impl EstimateFeeRate + Send + Sync + 'static,
>,
wallet: &dyn bitcoin_wallet::BitcoinWallet,
amount: Amount,
spending_fee: Amount,
) -> PartiallySignedTransaction {

View file

@ -1,10 +1,10 @@
use crate::bitcoin::wallet::Watchable;
use crate::bitcoin::{self, Address, Amount, PunishTimelock, Transaction, TxCancel, Txid};
use ::bitcoin::sighash::SighashCache;
use ::bitcoin::{secp256k1, sighash::SegwitV0Sighash as Sighash, EcdsaSighashType};
use ::bitcoin::{EcdsaSighashType, secp256k1, sighash::SegwitV0Sighash as Sighash};
use ::bitcoin::{ScriptBuf, Weight};
use anyhow::{Context, Result};
use bdk_wallet::miniscript::Descriptor;
use bitcoin_wallet::primitives::Watchable;
use std::collections::HashMap;
#[derive(Debug)]

View file

@ -1,18 +1,18 @@
use crate::bitcoin::wallet::Watchable;
use crate::bitcoin::{
verify_encsig, verify_sig, Address, Amount, EmptyWitnessStack, EncryptedSignature, NoInputs,
NotThreeWitnesses, PublicKey, SecretKey, TooManyInputs, Transaction, TxLock,
Address, Amount, EmptyWitnessStack, EncryptedSignature, NoInputs, NotThreeWitnesses, PublicKey,
SecretKey, TooManyInputs, Transaction, TxLock, verify_encsig, verify_sig,
};
use ::bitcoin::{sighash::SegwitV0Sighash as Sighash, Txid};
use anyhow::{bail, Context, Result};
use ::bitcoin::{Txid, sighash::SegwitV0Sighash as Sighash};
use anyhow::{Context, Result, bail};
use bdk_wallet::miniscript::Descriptor;
use bitcoin::sighash::SighashCache;
use bitcoin::{secp256k1, ScriptBuf};
use bitcoin::{EcdsaSighashType, Weight};
use bitcoin::{ScriptBuf, secp256k1};
use bitcoin_wallet::primitives::Watchable;
use ecdsa_fun::Signature;
use ecdsa_fun::adaptor::{Adaptor, HashTranscript};
use ecdsa_fun::fun::Scalar;
use ecdsa_fun::nonce::Deterministic;
use ecdsa_fun::Signature;
use sha2::Sha256;
use std::collections::HashMap;
use std::sync::Arc;

View file

@ -1,14 +1,15 @@
use crate::bitcoin::wallet::Watchable;
use crate::bitcoin;
use crate::bitcoin::{
verify_sig, Address, Amount, EmptyWitnessStack, NoInputs, NotThreeWitnesses, PublicKey,
TooManyInputs, Transaction, TxCancel,
Address, Amount, EmptyWitnessStack, NoInputs, NotThreeWitnesses, PublicKey, TooManyInputs,
Transaction, TxCancel, verify_sig,
};
use crate::{bitcoin, monero};
use ::bitcoin::sighash::SighashCache;
use ::bitcoin::{secp256k1, ScriptBuf, Weight};
use ::bitcoin::{sighash::SegwitV0Sighash as Sighash, EcdsaSighashType, Txid};
use anyhow::{bail, Context, Result};
use ::bitcoin::{EcdsaSighashType, Txid, sighash::SegwitV0Sighash as Sighash};
use ::bitcoin::{ScriptBuf, Weight, secp256k1};
use anyhow::{Context, Result, bail};
use bdk_wallet::miniscript::Descriptor;
use bitcoin_wallet::primitives::Watchable;
use curve25519_dalek::scalar::Scalar;
use ecdsa_fun::Signature;
use std::collections::HashMap;
use std::sync::Arc;
@ -103,12 +104,10 @@ impl TxRefund {
pub fn extract_monero_private_key(
&self,
published_refund_tx: Arc<bitcoin::Transaction>,
s_a: monero::Scalar,
s_a: Scalar,
a: bitcoin::SecretKey,
S_b_bitcoin: bitcoin::PublicKey,
) -> Result<monero::PrivateKey> {
let s_a = monero::PrivateKey { scalar: s_a };
) -> Result<Scalar> {
let tx_refund_sig = self
.extract_signature_by_key(published_refund_tx, a.public())
.context("Failed to extract signature from Bitcoin refund tx")?;
@ -117,7 +116,7 @@ impl TxRefund {
let s_b = bitcoin::recover(S_b_bitcoin, tx_refund_sig, tx_refund_encsig)
.context("Failed to recover Monero secret key from Bitcoin signature")?;
let s_b = monero::private_key_from_secp256k1_scalar(s_b.into());
let s_b = crate::monero::primitives::private_key_from_secp256k1_scalar(s_b.into());
let spend_key = s_a + s_b;

2
swap-core/src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod bitcoin;
pub mod monero;

5
swap-core/src/monero.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod ext;
pub mod primitives;
pub use ext::*;
pub use primitives::*;

View file

@ -5,7 +5,7 @@ pub trait ScalarExt {
fn to_secpfun_scalar(&self) -> ecdsa_fun::fun::Scalar;
}
impl ScalarExt for crate::monero::Scalar {
impl ScalarExt for curve25519_dalek::scalar::Scalar {
fn to_secpfun_scalar(&self) -> Scalar<Secret, NonZero> {
let mut little_endian_bytes = self.to_bytes();

View file

@ -0,0 +1,871 @@
use crate::bitcoin;
use anyhow::{Result, bail};
pub use curve25519_dalek::scalar::Scalar;
use monero::Address;
use rand::{CryptoRng, RngCore};
use rust_decimal::Decimal;
use rust_decimal::prelude::*;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use std::fmt;
use std::ops::{Add, Mul, Sub};
use std::str::FromStr;
use typeshare::typeshare;
use ::monero::network::Network;
pub use ::monero::{PrivateKey, PublicKey};
pub const PICONERO_OFFSET: u64 = 1_000_000_000_000;
/// A Monero block height.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct BlockHeight {
pub height: u64,
}
impl fmt::Display for BlockHeight {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.height)
}
}
pub fn private_key_from_secp256k1_scalar(scalar: bitcoin::Scalar) -> Scalar {
let mut bytes = scalar.to_bytes();
// we must reverse the bytes because a secp256k1 scalar is big endian, whereas a
// ed25519 scalar is little endian
bytes.reverse();
Scalar::from_bytes_mod_order(bytes)
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrivateViewKey(#[serde(with = "swap_serde::monero::private_key")] PrivateKey);
impl fmt::Display for PrivateViewKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Delegate to the Display implementation of PrivateKey
write!(f, "{}", self.0)
}
}
impl PrivateViewKey {
pub fn new_random<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
let scalar = Scalar::random(rng);
let private_key = PrivateKey::from_scalar(scalar);
Self(private_key)
}
pub fn public(&self) -> PublicViewKey {
PublicViewKey(PublicKey::from_private_key(&self.0))
}
}
impl Add for PrivateViewKey {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl From<PrivateViewKey> for PrivateKey {
fn from(from: PrivateViewKey) -> Self {
from.0
}
}
impl From<PublicViewKey> for PublicKey {
fn from(from: PublicViewKey) -> Self {
from.0
}
}
#[derive(Clone, Copy, Debug)]
pub struct PublicViewKey(pub PublicKey);
/// Our own monero amount type, which we need because the monero crate
/// doesn't implement Serialize and Deserialize.
#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd)]
#[typeshare(serialized_as = "number")]
pub struct Amount(u64);
// TX Fees on Monero can be found here:
// - https://www.monero.how/monero-transaction-fees
// - https://bitinfocharts.com/comparison/monero-transactionfees.html#1y
//
// In the last year the highest avg fee on any given day was around 0.00075 XMR
// We use a multiplier of 4x to stay safe
// 0.00075 XMR * 4 = 0.003 XMR (around $1 as of Jun. 4th 2025)
// We DO NOT use this fee to construct any transactions. It is only to **estimate** how much
// we need to reserve for the fee when determining our max giveable amount
// We use a VERY conservative value here to stay on the safe side. We want to avoid not being able
// to lock as much as we previously estimated.
pub const CONSERVATIVE_MONERO_FEE: Amount = Amount::from_piconero(3_000_000_000);
impl Amount {
pub const ZERO: Self = Self(0);
pub const ONE_XMR: Self = Self(PICONERO_OFFSET);
/// Create an [Amount] with piconero precision and the given number of
/// piconeros.
///
/// A piconero (a.k.a atomic unit) is equal to 1e-12 XMR.
pub const fn from_piconero(amount: u64) -> Self {
Amount(amount)
}
/// Return Monero Amount as Piconero.
pub fn as_piconero(&self) -> u64 {
self.0
}
/// Return Monero Amount as XMR.
pub fn as_xmr(&self) -> f64 {
let amount_decimal = Decimal::from(self.0);
let offset_decimal = Decimal::from(PICONERO_OFFSET);
let result = amount_decimal / offset_decimal;
// Convert to f64 only at the end, after the division
result
.to_f64()
.expect("Conversion from piconero to XMR should not overflow f64")
}
/// Calculate the conservative max giveable of Monero we can spent given [`self`] is the balance
/// of a Monero wallet
/// This is going to be LESS than we can really spent because we assume a high fee
pub fn max_conservative_giveable(&self) -> Self {
let pico_minus_fee = self
.as_piconero()
.saturating_sub(CONSERVATIVE_MONERO_FEE.as_piconero());
Self::from_piconero(pico_minus_fee)
}
/// Calculate the Monero balance needed to send the [`self`] Amount to another address
/// E.g: Amount(1 XMR).min_conservative_balance_to_spend() with a fee of 0.1 XMR would be 1.1 XMR
/// This is going to be MORE than we really need because we assume a high fee
pub fn min_conservative_balance_to_spend(&self) -> Self {
let pico_minus_fee = self
.as_piconero()
.saturating_add(CONSERVATIVE_MONERO_FEE.as_piconero());
Self::from_piconero(pico_minus_fee)
}
/// Calculate the maximum amount of Bitcoin that can be bought at a given
/// asking price for this amount of Monero including the median fee.
pub fn max_bitcoin_for_price(&self, ask_price: bitcoin::Amount) -> Option<bitcoin::Amount> {
let pico_minus_fee = self.max_conservative_giveable();
if pico_minus_fee.as_piconero() == 0 {
return Some(bitcoin::Amount::ZERO);
}
// safely convert the BTC/XMR rate to sat/pico
let ask_sats = Decimal::from(ask_price.to_sat());
let pico_per_xmr = Decimal::from(PICONERO_OFFSET);
let ask_sats_per_pico = ask_sats / pico_per_xmr;
let pico = Decimal::from(pico_minus_fee.as_piconero());
let max_sats = pico.checked_mul(ask_sats_per_pico)?;
let satoshi = max_sats.to_u64()?;
Some(bitcoin::Amount::from_sat(satoshi))
}
pub fn from_monero(amount: f64) -> Result<Self> {
let decimal = Decimal::try_from(amount)?;
Self::from_decimal(decimal)
}
pub fn parse_monero(amount: &str) -> Result<Self> {
let decimal = Decimal::from_str(amount)?;
Self::from_decimal(decimal)
}
pub fn as_piconero_decimal(&self) -> Decimal {
Decimal::from(self.as_piconero())
}
fn from_decimal(amount: Decimal) -> Result<Self> {
let piconeros_dec =
amount.mul(Decimal::from_u64(PICONERO_OFFSET).expect("constant to fit into u64"));
let piconeros = piconeros_dec
.to_u64()
.ok_or_else(|| OverflowError(amount.to_string()))?;
Ok(Amount(piconeros))
}
/// Subtract but throw an error on underflow.
pub fn checked_sub(self, rhs: Amount) -> Result<Self> {
if self.0 < rhs.0 {
bail!("checked sub would underflow");
}
Ok(Amount::from_piconero(self.0 - rhs.0))
}
}
/// A Monero address with an associated percentage and human-readable label.
///
/// This structure represents a destination address for Monero transactions
/// along with the percentage of funds it should receive and a descriptive label.
#[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: Option<monero::Address>,
#[typeshare(serialized_as = "number")]
percentage: Decimal,
label: String,
}
impl LabeledMoneroAddress {
/// Creates a new labeled Monero address.
///
/// # Arguments
///
/// * `address` - The Monero address
/// * `percentage` - The percentage of funds (between 0.0 and 1.0)
/// * `label` - A human-readable label for this address
///
/// # Errors
///
/// Returns an error if the percentage is not between 0.0 and 1.0 inclusive.
fn new(
address: impl Into<Option<monero::Address>>,
percentage: Decimal,
label: String,
) -> Result<Self> {
if percentage < Decimal::ZERO || percentage > Decimal::ONE {
bail!(
"Percentage must be between 0 and 1 inclusive, got: {}",
percentage
);
}
Ok(Self {
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) -> Option<monero::Address> {
self.address.clone()
}
/// Returns the percentage as a decimal.
pub fn percentage(&self) -> Decimal {
self.percentage
}
/// Returns the human-readable label.
pub fn label(&self) -> &str {
&self.label
}
}
/// A collection of labeled Monero addresses that can receive funds in a transaction.
///
/// This structure manages multiple destination addresses with their associated
/// percentages and labels. It's used for splitting Monero transactions across
/// multiple recipients, such as for donations or multi-destination swaps.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[typeshare]
pub struct MoneroAddressPool(Vec<LabeledMoneroAddress>);
use rust_decimal::prelude::ToPrimitive;
impl MoneroAddressPool {
/// Creates a new address pool from a vector of labeled addresses.
///
/// # Arguments
///
/// * `addresses` - Vector of labeled Monero addresses
pub fn new(addresses: Vec<LabeledMoneroAddress>) -> Self {
Self(addresses)
}
/// Returns a vector of all Monero addresses in the pool.
pub fn addresses(&self) -> Vec<Option<monero::Address>> {
self.0.iter().map(|address| address.address()).collect()
}
/// Returns a vector of all percentages as f64 values (0-1 range).
pub fn percentages(&self) -> Vec<f64> {
self.0
.iter()
.map(|address| {
address
.percentage()
.to_f64()
.expect("Decimal should convert to f64")
})
.collect()
}
/// Returns an iterator over the labeled addresses.
pub fn iter(&self) -> impl Iterator<Item = &LabeledMoneroAddress> {
self.0.iter()
}
/// Validates that all addresses in the pool are on the expected network.
///
/// # Arguments
///
/// * `network` - The expected Monero network
///
/// # Errors
///
/// 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 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
);
}
}
}
Ok(())
}
/// Assert that the sum of the percentages in the address pool is 1 (allowing for a small tolerance)
pub fn assert_sum_to_one(&self) -> Result<()> {
let sum = self
.0
.iter()
.map(|address| address.percentage())
.sum::<Decimal>();
const TOLERANCE: f64 = 1e-6;
if (sum - Decimal::ONE).abs()
> Decimal::from_f64(TOLERANCE).expect("TOLERANCE constant should be a valid f64")
{
bail!("Address pool percentages do not sum to 1");
}
Ok(())
}
/// Returns a vector of addresses with the empty addresses filled with the given primary address
pub fn fill_empty_addresses(&self, primary_address: monero::Address) -> Vec<monero::Address> {
self.0
.iter()
.map(|address| address.address().unwrap_or(primary_address))
.collect()
}
}
impl From<::monero::Address> for MoneroAddressPool {
fn from(address: ::monero::Address) -> Self {
Self(vec![
LabeledMoneroAddress::new(address, Decimal::from(1), "user address".to_string())
.expect("Percentage 1 is always valid"),
])
}
}
/// A request to watch for a transfer.
pub struct WatchRequest {
pub public_view_key: super::PublicViewKey,
pub public_spend_key: monero::PublicKey,
/// The proof of the transfer.
pub transfer_proof: TransferProof,
/// The expected amount of the transfer.
pub expected_amount: monero::Amount,
/// The number of confirmations required for the transfer to be considered confirmed.
pub confirmation_target: u64,
}
/// Transfer a specified amount of money to a specified address.
pub struct TransferRequest {
pub public_spend_key: monero::PublicKey,
pub public_view_key: super::PublicViewKey,
pub amount: monero::Amount,
}
impl TransferRequest {
pub fn address_and_amount(&self, network: Network) -> (Address, monero::Amount) {
(
Address::standard(network, self.public_spend_key, self.public_view_key.0),
self.amount,
)
}
}
impl Add for Amount {
type Output = Amount;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl Sub<Amount> for Amount {
type Output = Amount;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
impl Mul<u64> for Amount {
type Output = Amount;
fn mul(self, rhs: u64) -> Self::Output {
Self(self.0 * rhs)
}
}
impl From<Amount> for u64 {
fn from(from: Amount) -> u64 {
from.0
}
}
impl From<::monero::Amount> for Amount {
fn from(from: ::monero::Amount) -> Self {
Amount::from_piconero(from.as_pico())
}
}
impl From<Amount> for ::monero::Amount {
fn from(from: Amount) -> Self {
::monero::Amount::from_pico(from.as_piconero())
}
}
impl fmt::Display for Amount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut decimal = Decimal::from(self.0);
decimal
.set_scale(12)
.expect("12 is smaller than max precision of 28");
write!(f, "{} XMR", decimal)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TransferProof {
pub tx_hash: TxHash,
#[serde(with = "swap_serde::monero::private_key")]
pub tx_key: PrivateKey,
}
impl TransferProof {
pub fn new(tx_hash: TxHash, tx_key: PrivateKey) -> Self {
Self { tx_hash, tx_key }
}
pub fn tx_hash(&self) -> TxHash {
self.tx_hash.clone()
}
pub fn tx_key(&self) -> PrivateKey {
self.tx_key
}
}
// TODO: add constructor/ change String to fixed length byte array
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TxHash(pub String);
impl From<TxHash> for String {
fn from(from: TxHash) -> Self {
from.0
}
}
impl fmt::Debug for TxHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Display for TxHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[error("expected {expected}, got {actual}")]
pub struct InsufficientFunds {
pub expected: Amount,
pub actual: Amount,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[error("Overflow, cannot convert {0} to u64")]
pub struct OverflowError(pub String);
pub mod monero_amount {
use crate::monero::Amount;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(x: &Amount, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.serialize_u64(x.as_piconero())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Amount, <D as Deserializer<'de>>::Error>
where
D: Deserializer<'de>,
{
let picos = u64::deserialize(deserializer)?;
let amount = Amount::from_piconero(picos);
Ok(amount)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_monero_min() {
let min_pics = 1;
let amount = Amount::from_piconero(min_pics);
let monero = amount.to_string();
assert_eq!("0.000000000001 XMR", monero);
}
#[test]
fn display_monero_one() {
let min_pics = 1000000000000;
let amount = Amount::from_piconero(min_pics);
let monero = amount.to_string();
assert_eq!("1.000000000000 XMR", monero);
}
#[test]
fn display_monero_max() {
let max_pics = 18_446_744_073_709_551_615;
let amount = Amount::from_piconero(max_pics);
let monero = amount.to_string();
assert_eq!("18446744.073709551615 XMR", monero);
}
#[test]
fn parse_monero_min() {
let monero_min = "0.000000000001";
let amount = Amount::parse_monero(monero_min).unwrap();
let pics = amount.0;
assert_eq!(1, pics);
}
#[test]
fn parse_monero() {
let monero = "123";
let amount = Amount::parse_monero(monero).unwrap();
let pics = amount.0;
assert_eq!(123000000000000, pics);
}
#[test]
fn parse_monero_max() {
let monero = "18446744.073709551615";
let amount = Amount::parse_monero(monero).unwrap();
let pics = amount.0;
assert_eq!(18446744073709551615, pics);
}
#[test]
fn parse_monero_overflows() {
let overflow_pics = "18446744.073709551616";
let error = Amount::parse_monero(overflow_pics).unwrap_err();
assert_eq!(
error.downcast_ref::<OverflowError>().unwrap(),
&OverflowError(overflow_pics.to_owned())
);
}
#[test]
fn max_bitcoin_to_trade() {
// sanity check: if the asking price is 1 BTC / 1 XMR
// and we have μ XMR + fee
// then max BTC we can buy is μ
let ask = bitcoin::Amount::from_btc(1.0).unwrap();
let xmr = Amount::parse_monero("1.0").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(1.0).unwrap());
let xmr = Amount::parse_monero("0.5").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(0.5).unwrap());
let xmr = Amount::parse_monero("2.5").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(2.5).unwrap());
let xmr = Amount::parse_monero("420").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(420.0).unwrap());
let xmr = Amount::parse_monero("0.00001").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(0.00001).unwrap());
// other ask prices
let ask = bitcoin::Amount::from_btc(0.5).unwrap();
let xmr = Amount::parse_monero("2").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(1.0).unwrap());
let ask = bitcoin::Amount::from_btc(2.0).unwrap();
let xmr = Amount::parse_monero("1").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(2.0).unwrap());
let ask = bitcoin::Amount::from_sat(382_900);
let xmr = Amount::parse_monero("10").unwrap();
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_sat(3_827_851));
// example from https://github.com/comit-network/xmr-btc-swap/issues/1084
// with rate from kraken at that time
let ask = bitcoin::Amount::from_sat(685_800);
let xmr = Amount::parse_monero("0.826286435921").unwrap();
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_sat(564_609));
}
#[test]
fn max_bitcoin_to_trade_overflow() {
let xmr = Amount::from_monero(30.0).unwrap();
let ask = bitcoin::Amount::from_sat(728_688);
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(bitcoin::Amount::from_sat(21_858_453), btc);
let xmr = Amount::from_piconero(u64::MAX);
let ask = bitcoin::Amount::from_sat(u64::MAX);
let btc = xmr.max_bitcoin_for_price(ask);
assert!(btc.is_none());
}
#[test]
fn geting_max_bitcoin_to_trade_with_balance_smaller_than_locking_fee() {
let ask = bitcoin::Amount::from_sat(382_900);
let xmr = Amount::parse_monero("0.00001").unwrap();
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(bitcoin::Amount::ZERO, btc);
}
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MoneroPrivateKey(
#[serde(with = "swap_serde::monero::private_key")] ::monero::PrivateKey,
);
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MoneroAmount(#[serde(with = "swap_serde::monero::amount")] ::monero::Amount);
#[test]
fn serde_monero_private_key_json() {
let key = MoneroPrivateKey(monero::PrivateKey::from_scalar(Scalar::random(&mut OsRng)));
let encoded = serde_json::to_vec(&key).unwrap();
let decoded: MoneroPrivateKey = serde_json::from_slice(&encoded).unwrap();
assert_eq!(key, decoded);
}
#[test]
fn serde_monero_private_key_cbor() {
let key = MoneroPrivateKey(monero::PrivateKey::from_scalar(Scalar::random(&mut OsRng)));
let encoded = serde_cbor::to_vec(&key).unwrap();
let decoded: MoneroPrivateKey = serde_cbor::from_slice(&encoded).unwrap();
assert_eq!(key, decoded);
}
#[test]
fn serde_monero_amount() {
let amount = MoneroAmount(::monero::Amount::from_pico(1000));
let encoded = serde_cbor::to_vec(&amount).unwrap();
let decoded: MoneroAmount = serde_cbor::from_slice(&encoded).unwrap();
assert_eq!(amount, decoded);
}
#[test]
fn max_conservative_giveable_basic() {
// Test with balance larger than fee
let balance = Amount::parse_monero("1.0").unwrap();
let giveable = balance.max_conservative_giveable();
let expected = balance.as_piconero() - CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(giveable.as_piconero(), expected);
}
#[test]
fn max_conservative_giveable_exact_fee() {
// Test with balance exactly equal to fee
let balance = CONSERVATIVE_MONERO_FEE;
let giveable = balance.max_conservative_giveable();
assert_eq!(giveable, Amount::ZERO);
}
#[test]
fn max_conservative_giveable_less_than_fee() {
// Test with balance less than fee (should saturate to 0)
let balance = Amount::from_piconero(CONSERVATIVE_MONERO_FEE.as_piconero() / 2);
let giveable = balance.max_conservative_giveable();
assert_eq!(giveable, Amount::ZERO);
}
#[test]
fn max_conservative_giveable_zero_balance() {
// Test with zero balance
let balance = Amount::ZERO;
let giveable = balance.max_conservative_giveable();
assert_eq!(giveable, Amount::ZERO);
}
#[test]
fn max_conservative_giveable_large_balance() {
// Test with large balance
let balance = Amount::parse_monero("100.0").unwrap();
let giveable = balance.max_conservative_giveable();
let expected = balance.as_piconero() - CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(giveable.as_piconero(), expected);
// Ensure the result makes sense
assert!(giveable.as_piconero() > 0);
assert!(giveable < balance);
}
#[test]
fn min_conservative_balance_to_spend_basic() {
// Test with 1 XMR amount to send
let amount_to_send = Amount::parse_monero("1.0").unwrap();
let min_balance = amount_to_send.min_conservative_balance_to_spend();
let expected = amount_to_send.as_piconero() + CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(min_balance.as_piconero(), expected);
}
#[test]
fn min_conservative_balance_to_spend_zero() {
// Test with zero amount to send
let amount_to_send = Amount::ZERO;
let min_balance = amount_to_send.min_conservative_balance_to_spend();
assert_eq!(min_balance, CONSERVATIVE_MONERO_FEE);
}
#[test]
fn min_conservative_balance_to_spend_small_amount() {
// Test with small amount
let amount_to_send = Amount::from_piconero(1000);
let min_balance = amount_to_send.min_conservative_balance_to_spend();
let expected = 1000 + CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(min_balance.as_piconero(), expected);
}
#[test]
fn min_conservative_balance_to_spend_large_amount() {
// Test with large amount
let amount_to_send = Amount::parse_monero("50.0").unwrap();
let min_balance = amount_to_send.min_conservative_balance_to_spend();
let expected = amount_to_send.as_piconero() + CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(min_balance.as_piconero(), expected);
// Ensure the result makes sense
assert!(min_balance > amount_to_send);
assert!(min_balance > CONSERVATIVE_MONERO_FEE);
}
#[test]
fn conservative_fee_functions_are_inverse() {
// Test that the functions are somewhat inverse of each other
let original_balance = Amount::parse_monero("5.0").unwrap();
// Get max giveable amount
let max_giveable = original_balance.max_conservative_giveable();
// Calculate min balance needed to send that amount
let min_balance_needed = max_giveable.min_conservative_balance_to_spend();
// The min balance needed should be equal to or slightly more than the original balance
// (due to the conservative nature of the fee estimation)
assert!(min_balance_needed >= original_balance);
// The difference should be at most the conservative fee
let difference = min_balance_needed.as_piconero() - original_balance.as_piconero();
assert!(difference <= CONSERVATIVE_MONERO_FEE.as_piconero());
}
#[test]
fn conservative_fee_edge_cases() {
// Test with maximum possible amount
let max_amount = Amount::from_piconero(u64::MAX - CONSERVATIVE_MONERO_FEE.as_piconero());
let giveable = max_amount.max_conservative_giveable();
assert!(giveable.as_piconero() > 0);
// Test min balance calculation doesn't overflow
let large_amount = Amount::from_piconero(u64::MAX / 2);
let min_balance = large_amount.min_conservative_balance_to_spend();
assert!(min_balance > large_amount);
}
#[test]
fn labeled_monero_address_percentage_validation() {
use rust_decimal::Decimal;
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());
assert!(LabeledMoneroAddress::new(address, Decimal::ONE, "test".to_string()).is_ok());
assert!(LabeledMoneroAddress::new(address, Decimal::new(5, 1), "test".to_string()).is_ok()); // 0.5
assert!(
LabeledMoneroAddress::new(address, Decimal::new(9925, 4), "test".to_string()).is_ok()
); // 0.9925
// Invalid percentages should fail
assert!(
LabeledMoneroAddress::new(address, Decimal::new(-1, 0), "test".to_string()).is_err()
);
assert!(
LabeledMoneroAddress::new(address, Decimal::new(11, 1), "test".to_string()).is_err()
); // 1.1
assert!(
LabeledMoneroAddress::new(address, Decimal::new(2, 0), "test".to_string()).is_err()
); // 2.0
}
}

View file

@ -1,7 +1,7 @@
use crate::defaults::GetDefaults;
use crate::env::{Mainnet, Testnet};
use crate::prompt;
use anyhow::{bail, Context, Result};
use anyhow::{Context, Result, bail};
use config::ConfigError;
use libp2p::core::Multiaddr;
use rust_decimal::Decimal;

View file

@ -1,15 +1,15 @@
use std::path::{Path, PathBuf};
use crate::defaults::{
default_rendezvous_points, DEFAULT_MAX_BUY_AMOUNT, DEFAULT_MIN_BUY_AMOUNT, DEFAULT_SPREAD,
DEFAULT_MAX_BUY_AMOUNT, DEFAULT_MIN_BUY_AMOUNT, DEFAULT_SPREAD, default_rendezvous_points,
};
use anyhow::{bail, Context, Result};
use anyhow::{Context, Result, bail};
use console::Style;
use dialoguer::Confirm;
use dialoguer::{theme::ColorfulTheme, Input, Select};
use dialoguer::{Input, Select, theme::ColorfulTheme};
use libp2p::Multiaddr;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use url::Url;
/// Prompt user for data directory

View file

@ -177,8 +177,9 @@ mod tests {
.sell_quote(bitcoin::Amount::ONE_BTC)
.unwrap();
let xmr_factor = xmr_no_spread.into().as_piconero_decimal()
/ xmr_with_spread.into().as_piconero_decimal()
let xmr_factor = Decimal::from_f64_retain(f64::from(xmr_no_spread.as_pico() as u32))
.unwrap()
/ Decimal::from_f64_retain(f64::from(xmr_with_spread.as_pico() as u32)).unwrap()
- ONE;
assert!(xmr_with_spread < xmr_no_spread);

29
swap-machine/Cargo.toml Normal file
View file

@ -0,0 +1,29 @@
[package]
name = "swap-machine"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
bitcoin = { workspace = true }
bitcoin-wallet = { path = "../bitcoin-wallet" }
conquer-once = "0.4"
curve25519-dalek = { workspace = true }
ecdsa_fun = { workspace = true }
libp2p = { workspace = true }
monero = { workspace = true }
rand = { workspace = true }
rand_chacha = { workspace = true }
serde = { workspace = true }
sha2 = { workspace = true }
sigma_fun = { workspace = true, default-features = false, features = ["ed25519", "serde", "secp256k1", "alloc"] }
swap-core = { path = "../swap-core" }
swap-env = { path = "../swap-env" }
swap-serde = { path = "../swap-serde" }
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
[lints]
workspace = true

View file

@ -1,19 +1,17 @@
use crate::bitcoin::{
current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel,
TxEarlyRefund, TxPunish, TxRedeem, TxRefund, Txid,
};
use crate::monero::wallet::{TransferRequest, WatchRequest};
use crate::monero::BlockHeight;
use crate::monero::TransferProof;
use crate::monero_ext::ScalarExt;
use crate::protocol::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM};
use crate::{bitcoin, monero};
use anyhow::{anyhow, bail, Context, Result};
use crate::common::{CROSS_CURVE_PROOF_SYSTEM, Message0, Message1, Message2, Message3, Message4};
use anyhow::{Context, Result, anyhow, bail};
use rand::{CryptoRng, RngCore};
use serde::{Deserialize, Serialize};
use sigma_fun::ext::dl_secp256k1_ed25519_eq::CrossCurveDLEQProof;
use std::fmt;
use std::sync::Arc;
use swap_core::bitcoin::{
CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, TxEarlyRefund,
TxPunish, TxRedeem, TxRefund, Txid, current_epoch,
};
use swap_core::monero;
use swap_core::monero::ScalarExt;
use swap_core::monero::primitives::{BlockHeight, TransferProof, TransferRequest, WatchRequest};
use swap_env::env::Config;
use uuid::Uuid;
@ -49,7 +47,7 @@ pub enum AliceState {
EncSigLearned {
monero_wallet_restore_blockheight: BlockHeight,
transfer_proof: TransferProof,
encrypted_signature: Box<bitcoin::EncryptedSignature>,
encrypted_signature: Box<swap_core::bitcoin::EncryptedSignature>,
state3: Box<State3>,
},
BtcRedeemTransactionPublished {
@ -87,6 +85,17 @@ pub enum AliceState {
SafelyAborted,
}
pub fn is_complete(state: &AliceState) -> bool {
matches!(
state,
AliceState::XmrRefunded
| AliceState::BtcRedeemed
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted
| AliceState::BtcEarlyRefunded(_)
)
}
impl fmt::Display for AliceState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@ -118,13 +127,14 @@ impl fmt::Display for AliceState {
}
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, PartialEq)]
pub struct State0 {
a: bitcoin::SecretKey,
a: swap_core::bitcoin::SecretKey,
s_a: monero::Scalar,
v_a: monero::PrivateViewKey,
S_a_monero: monero::PublicKey,
S_a_bitcoin: bitcoin::PublicKey,
S_a_bitcoin: swap_core::bitcoin::PublicKey,
dleq_proof_s_a: CrossCurveDLEQProof,
btc: bitcoin::Amount,
xmr: monero::Amount,
@ -151,7 +161,7 @@ impl State0 {
where
R: RngCore + CryptoRng,
{
let a = bitcoin::SecretKey::new_random(rng);
let a = swap_core::bitcoin::SecretKey::new_random(rng);
let v_a = monero::PrivateViewKey::new_random(rng);
let s_a = monero::Scalar::random(rng);
@ -224,15 +234,16 @@ impl State0 {
}
}
#[allow(non_snake_case)]
#[derive(Clone, Debug)]
pub struct State1 {
a: bitcoin::SecretKey,
B: bitcoin::PublicKey,
s_a: monero::Scalar,
a: swap_core::bitcoin::SecretKey,
B: swap_core::bitcoin::PublicKey,
s_a: swap_core::monero::Scalar,
S_a_monero: monero::PublicKey,
S_a_bitcoin: bitcoin::PublicKey,
S_a_bitcoin: swap_core::bitcoin::PublicKey,
S_b_monero: monero::PublicKey,
S_b_bitcoin: bitcoin::PublicKey,
S_b_bitcoin: swap_core::bitcoin::PublicKey,
v: monero::PrivateViewKey,
v_a: monero::PrivateViewKey,
dleq_proof_s_a: CrossCurveDLEQProof,
@ -265,8 +276,9 @@ impl State1 {
}
pub fn receive(self, msg: Message2) -> Result<State2> {
let tx_lock = bitcoin::TxLock::from_psbt(msg.psbt, self.a.public(), self.B, self.btc)
.context("Failed to re-construct TxLock from received PSBT")?;
let tx_lock =
swap_core::bitcoin::TxLock::from_psbt(msg.psbt, self.a.public(), self.B, self.btc)
.context("Failed to re-construct TxLock from received PSBT")?;
Ok(State2 {
a: self.a,
@ -291,13 +303,14 @@ impl State1 {
}
}
#[allow(non_snake_case)]
#[derive(Clone, Debug)]
pub struct State2 {
a: bitcoin::SecretKey,
B: bitcoin::PublicKey,
a: swap_core::bitcoin::SecretKey,
B: swap_core::bitcoin::PublicKey,
s_a: monero::Scalar,
S_b_monero: monero::PublicKey,
S_b_bitcoin: bitcoin::PublicKey,
S_b_bitcoin: swap_core::bitcoin::PublicKey,
v: monero::PrivateViewKey,
btc: bitcoin::Amount,
xmr: monero::Amount,
@ -306,7 +319,7 @@ pub struct State2 {
refund_address: bitcoin::Address,
redeem_address: bitcoin::Address,
punish_address: bitcoin::Address,
tx_lock: bitcoin::TxLock,
tx_lock: swap_core::bitcoin::TxLock,
tx_redeem_fee: bitcoin::Amount,
tx_punish_fee: bitcoin::Amount,
tx_refund_fee: bitcoin::Amount,
@ -315,7 +328,7 @@ pub struct State2 {
impl State2 {
pub fn next_message(&self) -> Message3 {
let tx_cancel = bitcoin::TxCancel::new(
let tx_cancel = swap_core::bitcoin::TxCancel::new(
&self.tx_lock,
self.cancel_timelock,
self.a.public(),
@ -325,7 +338,7 @@ impl State2 {
.expect("valid cancel tx");
let tx_refund =
bitcoin::TxRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee);
swap_core::bitcoin::TxRefund::new(&tx_cancel, &self.refund_address, self.tx_refund_fee);
// Alice encsigns the refund transaction(bitcoin) digest with Bob's monero
// pubkey(S_b). The refund transaction spends the output of
// tx_lock_bitcoin to Bob's refund address.
@ -342,7 +355,7 @@ impl State2 {
pub fn receive(self, msg: Message4) -> Result<State3> {
// Create the TxCancel transaction ourself
let tx_cancel = bitcoin::TxCancel::new(
let tx_cancel = swap_core::bitcoin::TxCancel::new(
&self.tx_lock,
self.cancel_timelock,
self.a.public(),
@ -351,11 +364,11 @@ impl State2 {
)?;
// Check if the provided signature by Bob is valid for the transaction
bitcoin::verify_sig(&self.B, &tx_cancel.digest(), &msg.tx_cancel_sig)
swap_core::bitcoin::verify_sig(&self.B, &tx_cancel.digest(), &msg.tx_cancel_sig)
.context("Failed to verify cancel transaction")?;
// Create the TxPunish transaction ourself
let tx_punish = bitcoin::TxPunish::new(
let tx_punish = swap_core::bitcoin::TxPunish::new(
&tx_cancel,
&self.punish_address,
self.punish_timelock,
@ -363,16 +376,23 @@ impl State2 {
);
// Check if the provided signature by Bob is valid for the transaction
bitcoin::verify_sig(&self.B, &tx_punish.digest(), &msg.tx_punish_sig)
swap_core::bitcoin::verify_sig(&self.B, &tx_punish.digest(), &msg.tx_punish_sig)
.context("Failed to verify punish transaction")?;
// Create the TxEarlyRefund transaction ourself
let tx_early_refund =
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee);
let tx_early_refund = swap_core::bitcoin::TxEarlyRefund::new(
&self.tx_lock,
&self.refund_address,
self.tx_refund_fee,
);
// Check if the provided signature by Bob is valid for the transaction
bitcoin::verify_sig(&self.B, &tx_early_refund.digest(), &msg.tx_early_refund_sig)
.context("Failed to verify early refund transaction")?;
swap_core::bitcoin::verify_sig(
&self.B,
&tx_early_refund.digest(),
&msg.tx_early_refund_sig,
)
.context("Failed to verify early refund transaction")?;
Ok(State3 {
a: self.a,
@ -400,13 +420,14 @@ impl State2 {
}
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct State3 {
a: bitcoin::SecretKey,
B: bitcoin::PublicKey,
a: swap_core::bitcoin::SecretKey,
B: swap_core::bitcoin::PublicKey,
pub s_a: monero::Scalar,
S_b_monero: monero::PublicKey,
S_b_bitcoin: bitcoin::PublicKey,
S_b_bitcoin: swap_core::bitcoin::PublicKey,
pub v: monero::PrivateViewKey,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
pub btc: bitcoin::Amount,
@ -419,9 +440,9 @@ pub struct State3 {
redeem_address: bitcoin::Address,
#[serde(with = "swap_serde::bitcoin::address_serde")]
punish_address: bitcoin::Address,
pub tx_lock: bitcoin::TxLock,
tx_punish_sig_bob: bitcoin::Signature,
tx_cancel_sig_bob: bitcoin::Signature,
pub tx_lock: swap_core::bitcoin::TxLock,
tx_punish_sig_bob: swap_core::bitcoin::Signature,
tx_cancel_sig_bob: swap_core::bitcoin::Signature,
/// This field was added in this pull request:
/// https://github.com/eigenwallet/core/pull/344
///
@ -432,7 +453,7 @@ pub struct State3 {
/// to allow Alice to refund the Bitcoin early. If it is not present, Bob will have
/// to wait for the timelock to expire.
#[serde(default)]
tx_early_refund_sig_bob: Option<bitcoin::Signature>,
tx_early_refund_sig_bob: Option<swap_core::bitcoin::Signature>,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
tx_redeem_fee: bitcoin::Amount,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
@ -446,7 +467,7 @@ pub struct State3 {
impl State3 {
pub async fn expired_timelocks(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<ExpiredTimelocks> {
let tx_cancel = self.tx_cancel();
@ -505,7 +526,11 @@ impl State3 {
}
pub fn tx_refund(&self) -> TxRefund {
bitcoin::TxRefund::new(&self.tx_cancel(), &self.refund_address, self.tx_refund_fee)
swap_core::bitcoin::TxRefund::new(
&self.tx_cancel(),
&self.refund_address,
self.tx_refund_fee,
)
}
pub fn tx_redeem(&self) -> TxRedeem {
@ -513,24 +538,30 @@ impl State3 {
}
pub fn tx_early_refund(&self) -> TxEarlyRefund {
bitcoin::TxEarlyRefund::new(&self.tx_lock, &self.refund_address, self.tx_refund_fee)
swap_core::bitcoin::TxEarlyRefund::new(
&self.tx_lock,
&self.refund_address,
self.tx_refund_fee,
)
}
pub fn extract_monero_private_key(
&self,
published_refund_tx: Arc<bitcoin::Transaction>,
) -> Result<monero::PrivateKey> {
self.tx_refund().extract_monero_private_key(
published_refund_tx,
self.s_a,
self.a.clone(),
self.S_b_bitcoin,
)
Ok(monero::PrivateKey::from_scalar(
self.tx_refund().extract_monero_private_key(
published_refund_tx,
self.s_a,
self.a.clone(),
self.S_b_bitcoin,
)?,
))
}
pub async fn check_for_tx_cancel(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Option<Arc<Transaction>>> {
let tx_cancel = self.tx_cancel();
let tx = bitcoin_wallet
@ -543,7 +574,7 @@ impl State3 {
pub async fn fetch_tx_refund(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Option<Arc<Transaction>>> {
let tx_refund = self.tx_refund();
let tx = bitcoin_wallet
@ -554,59 +585,19 @@ impl State3 {
Ok(tx)
}
pub async fn submit_tx_cancel(&self, bitcoin_wallet: &bitcoin::Wallet) -> Result<Txid> {
pub async fn submit_tx_cancel(
&self,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Txid> {
let transaction = self.signed_cancel_transaction()?;
let (tx_id, _) = bitcoin_wallet.broadcast(transaction, "cancel").await?;
Ok(tx_id)
}
pub async fn refund_xmr(
pub async fn punish_btc(
&self,
monero_wallet: Arc<monero::Wallets>,
swap_id: Uuid,
spend_key: monero::PrivateKey,
transfer_proof: TransferProof,
) -> Result<()> {
let view_key = self.v;
// Ensure that the XMR to be refunded are spendable by awaiting 10 confirmations
// on the lock transaction.
tracing::info!("Waiting for Monero lock transaction to be confirmed");
let transfer_proof_2 = transfer_proof.clone();
monero_wallet
.wait_until_confirmed(
self.lock_xmr_watch_request(transfer_proof_2, 10),
Some(move |(confirmations, target_confirmations)| {
tracing::debug!(
%confirmations,
%target_confirmations,
"Monero lock transaction got a confirmation"
);
}),
)
.await
.context("Failed to wait for Monero lock transaction to be confirmed")?;
tracing::info!("Refunding Monero");
tracing::debug!(%swap_id, "Opening temporary Monero wallet from keys");
let swap_wallet = monero_wallet
.swap_wallet(swap_id, spend_key, view_key, transfer_proof.tx_hash())
.await
.context(format!("Failed to open/create swap wallet `{}`", swap_id))?;
tracing::debug!(%swap_id, "Sweeping Monero to redeem address");
let main_address = monero_wallet.main_wallet().await.main_address().await;
swap_wallet
.sweep(&main_address)
.await
.context("Failed to sweep Monero to redeem address")?;
Ok(())
}
pub async fn punish_btc(&self, bitcoin_wallet: &bitcoin::Wallet) -> Result<Txid> {
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Txid> {
let signed_tx_punish = self.signed_punish_transaction()?;
let (txid, subscription) = bitcoin_wallet.broadcast(signed_tx_punish, "punish").await?;
@ -617,9 +608,9 @@ impl State3 {
pub fn signed_redeem_transaction(
&self,
sig: bitcoin::EncryptedSignature,
sig: swap_core::bitcoin::EncryptedSignature,
) -> Result<bitcoin::Transaction> {
bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee)
swap_core::bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee)
.complete(sig, self.a.clone(), self.s_a.to_secpfun_scalar(), self.B)
.context("Failed to complete Bitcoin redeem transaction")
}
@ -653,7 +644,7 @@ impl State3 {
}
fn tx_punish(&self) -> TxPunish {
bitcoin::TxPunish::new(
swap_core::bitcoin::TxPunish::new(
&self.tx_cancel(),
&self.punish_address,
self.punish_timelock,
@ -663,9 +654,11 @@ impl State3 {
pub async fn watch_for_btc_tx_refund(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<monero::PrivateKey> {
let tx_refund_status = bitcoin_wallet.subscribe_to(self.tx_refund()).await;
let tx_refund_status = bitcoin_wallet
.subscribe_to(Box::new(self.tx_refund()))
.await;
tx_refund_status
.wait_until_seen()

View file

@ -1,17 +1,9 @@
use crate::bitcoin::wallet::{EstimateFeeRate, Subscription};
use crate::bitcoin::{
self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel,
TxLock, Txid, Wallet,
};
use crate::monero::wallet::WatchRequest;
use crate::monero::TransferProof;
use crate::monero::{self, MoneroAddressPool, TxHash};
use crate::monero_ext::ScalarExt;
use crate::protocol::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM};
use anyhow::{anyhow, bail, Context, Result};
use crate::common::{CROSS_CURVE_PROOF_SYSTEM, Message0, Message1, Message2, Message3, Message4};
use anyhow::{Context, Result, anyhow, bail};
use bitcoin_wallet::primitives::Subscription;
use ecdsa_fun::Signature;
use ecdsa_fun::adaptor::{Adaptor, HashTranscript};
use ecdsa_fun::nonce::Deterministic;
use ecdsa_fun::Signature;
use monero::BlockHeight;
use rand::{CryptoRng, RngCore};
use serde::{Deserialize, Serialize};
@ -19,6 +11,13 @@ use sha2::Sha256;
use sigma_fun::ext::dl_secp256k1_ed25519_eq::CrossCurveDLEQProof;
use std::fmt;
use std::sync::Arc;
use swap_core::bitcoin::{
self, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, TxLock, Txid,
current_epoch,
};
use swap_core::monero::ScalarExt;
use swap_core::monero::primitives::WatchRequest;
use swap_core::monero::{self, TransferProof};
use swap_serde::bitcoin::address_serde;
use uuid::Uuid;
@ -98,7 +97,7 @@ impl BobState {
/// Depending on the State, there are no locks to expire.
pub async fn expired_timelocks(
&self,
bitcoin_wallet: Arc<Wallet>,
bitcoin_wallet: Arc<dyn bitcoin_wallet::BitcoinWallet>,
) -> Result<Option<ExpiredTimelocks>> {
Ok(match self.clone() {
BobState::Started { .. }
@ -107,16 +106,16 @@ impl BobState {
| BobState::SwapSetupCompleted(_) => None,
BobState::BtcLocked { state3: state, .. }
| BobState::XmrLockProofReceived { state, .. } => {
Some(state.expired_timelock(&bitcoin_wallet).await?)
Some(state.expired_timelock(bitcoin_wallet.as_ref()).await?)
}
BobState::XmrLocked(state) | BobState::EncSigSent(state) => {
Some(state.expired_timelock(&bitcoin_wallet).await?)
Some(state.expired_timelock(bitcoin_wallet.as_ref()).await?)
}
BobState::CancelTimelockExpired(state)
| BobState::BtcCancelled(state)
| BobState::BtcRefundPublished(state)
| BobState::BtcEarlyRefundPublished(state) => {
Some(state.expired_timelock(&bitcoin_wallet).await?)
Some(state.expired_timelock(bitcoin_wallet.as_ref()).await?)
}
BobState::BtcPunished { .. } => Some(ExpiredTimelocks::Punish),
BobState::BtcRefunded(_)
@ -127,6 +126,17 @@ impl BobState {
}
}
pub fn is_complete(state: &BobState) -> bool {
matches!(
state,
BobState::BtcRefunded(..)
| BobState::BtcEarlyRefunded { .. }
| BobState::XmrRedeemed { .. }
| BobState::SafelyAborted
)
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, PartialEq)]
pub struct State0 {
swap_id: Uuid,
@ -207,10 +217,7 @@ impl State0 {
pub async fn receive(
self,
wallet: &bitcoin::Wallet<
bdk_wallet::rusqlite::Connection,
impl EstimateFeeRate + Send + Sync + 'static,
>,
wallet: &dyn bitcoin_wallet::BitcoinWallet,
msg: Message1,
) -> Result<State1> {
let valid = CROSS_CURVE_PROOF_SYSTEM.verify(
@ -228,7 +235,7 @@ impl State0 {
bail!("Alice's dleq proof doesn't verify")
}
let tx_lock = bitcoin::TxLock::new(
let tx_lock = swap_core::bitcoin::TxLock::new(
wallet,
self.btc,
self.tx_lock_fee,
@ -262,6 +269,7 @@ impl State0 {
}
}
#[allow(non_snake_case)]
#[derive(Debug)]
pub struct State1 {
A: bitcoin::PublicKey,
@ -336,6 +344,7 @@ impl State1 {
}
}
#[allow(non_snake_case)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct State2 {
A: bitcoin::PublicKey,
@ -427,6 +436,7 @@ impl State2 {
}
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct State3 {
A: bitcoin::PublicKey,
@ -525,7 +535,7 @@ impl State3 {
pub async fn expired_timelock(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<ExpiredTimelocks> {
let tx_cancel = TxCancel::new(
&self.tx_lock,
@ -552,7 +562,7 @@ impl State3 {
pub async fn check_for_tx_early_refund(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Option<Arc<Transaction>>> {
let tx_early_refund = self.construct_tx_early_refund();
let tx = bitcoin_wallet
@ -564,6 +574,7 @@ impl State3 {
}
}
#[allow(non_snake_case)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct State4 {
A: bitcoin::PublicKey,
@ -594,7 +605,7 @@ pub struct State4 {
impl State4 {
pub async fn check_for_tx_redeem(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Option<State5>> {
let tx_redeem =
bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee);
@ -606,7 +617,9 @@ impl State4 {
let tx_redeem_sig =
tx_redeem.extract_signature_by_key(tx_redeem_candidate, self.b.public())?;
let s_a = bitcoin::recover(self.S_a_bitcoin, tx_redeem_sig, tx_redeem_encsig)?;
let s_a = monero::private_key_from_secp256k1_scalar(s_a.into());
let s_a = monero::PrivateKey::from_scalar(monero::private_key_from_secp256k1_scalar(
s_a.into(),
));
Ok(Some(State5 {
s_a,
@ -628,12 +641,15 @@ impl State4 {
self.b.encsign(self.S_a_bitcoin, tx_redeem.digest())
}
pub async fn watch_for_redeem_btc(&self, bitcoin_wallet: &bitcoin::Wallet) -> Result<State5> {
pub async fn watch_for_redeem_btc(
&self,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<State5> {
let tx_redeem =
bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address, self.tx_redeem_fee);
bitcoin_wallet
.subscribe_to(tx_redeem.clone())
.subscribe_to(Box::new(tx_redeem))
.await
.wait_until_seen()
.await?;
@ -647,7 +663,7 @@ impl State4 {
pub async fn expired_timelock(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<ExpiredTimelocks> {
let tx_cancel = TxCancel::new(
&self.tx_lock,
@ -716,13 +732,13 @@ impl State5 {
self.tx_lock.txid()
}
pub fn lock_xmr_watch_request_for_sweep(&self) -> monero::wallet::WatchRequest {
pub fn lock_xmr_watch_request_for_sweep(&self) -> swap_core::monero::primitives::WatchRequest {
let S_b_monero =
monero::PublicKey::from_private_key(&monero::PrivateKey::from_scalar(self.s_b));
let S_a_monero = monero::PublicKey::from_private_key(&self.s_a);
let S = S_a_monero + S_b_monero;
monero::wallet::WatchRequest {
swap_core::monero::primitives::WatchRequest {
public_spend_key: S,
public_view_key: self.v.public(),
transfer_proof: self.lock_transfer_proof.clone(),
@ -732,50 +748,9 @@ impl State5 {
expected_amount: self.xmr.into(),
}
}
pub async fn redeem_xmr(
&self,
monero_wallet: &monero::Wallets,
swap_id: Uuid,
monero_receive_pool: MoneroAddressPool,
) -> Result<Vec<TxHash>> {
let (spend_key, view_key) = self.xmr_keys();
tracing::info!(%swap_id, "Redeeming Monero from extracted keys");
tracing::debug!(%swap_id, "Opening temporary Monero wallet");
let wallet = monero_wallet
.swap_wallet(
swap_id,
spend_key,
view_key,
self.lock_transfer_proof.tx_hash(),
)
.await
.context("Failed to open Monero wallet")?;
tracing::debug!(%swap_id, receive_address=?monero_receive_pool, "Sweeping Monero to receive address");
let main_address = monero_wallet.main_wallet().await.main_address().await;
let tx_hashes = wallet
.sweep_multi_destination(
&monero_receive_pool.fill_empty_addresses(main_address),
&monero_receive_pool.percentages(),
)
.await
.context("Failed to redeem Monero")?
.into_iter()
.map(|tx_receipt| TxHash(tx_receipt.txid))
.collect();
tracing::info!(%swap_id, txids=?tx_hashes, "Monero sweep completed");
Ok(tx_hashes)
}
}
#[allow(non_snake_case)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct State6 {
A: bitcoin::PublicKey,
@ -800,7 +775,7 @@ pub struct State6 {
impl State6 {
pub async fn expired_timelock(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<ExpiredTimelocks> {
let tx_cancel = TxCancel::new(
&self.tx_lock,
@ -833,7 +808,7 @@ impl State6 {
pub async fn check_for_tx_cancel(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Option<Arc<Transaction>>> {
let tx_cancel = self.construct_tx_cancel()?;
@ -847,7 +822,7 @@ impl State6 {
pub async fn submit_tx_cancel(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<(Txid, Subscription)> {
let transaction = self
.construct_tx_cancel()?
@ -861,7 +836,7 @@ impl State6 {
pub async fn publish_refund_btc(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<bitcoin::Txid> {
let signed_tx_refund = self.signed_refund_transaction()?;
let signed_tx_refund_txid = signed_tx_refund.compute_txid();
@ -922,7 +897,7 @@ impl State6 {
pub async fn check_for_tx_early_refund(
&self,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: &dyn bitcoin_wallet::BitcoinWallet,
) -> Result<Option<Arc<Transaction>>> {
let tx_early_refund = self.construct_tx_early_refund();

View file

@ -0,0 +1,169 @@
use crate::alice::AliceState;
use crate::alice::is_complete as alice_is_complete;
use crate::bob::BobState;
use crate::bob::is_complete as bob_is_complete;
use anyhow::Result;
use async_trait::async_trait;
use conquer_once::Lazy;
use libp2p::{Multiaddr, PeerId};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use sigma_fun::HashTranscript;
use sigma_fun::ext::dl_secp256k1_ed25519_eq::{CrossCurveDLEQ, CrossCurveDLEQProof};
use std::convert::TryInto;
use swap_core::bitcoin;
use swap_core::monero::{self, MoneroAddressPool};
use uuid::Uuid;
pub static CROSS_CURVE_PROOF_SYSTEM: Lazy<
CrossCurveDLEQ<HashTranscript<Sha256, rand_chacha::ChaCha20Rng>>,
> = Lazy::new(|| {
CrossCurveDLEQ::<HashTranscript<Sha256, rand_chacha::ChaCha20Rng>>::new(
(*ecdsa_fun::fun::G).normalize(),
curve25519_dalek::constants::ED25519_BASEPOINT_POINT,
)
});
#[allow(non_snake_case)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message0 {
pub swap_id: Uuid,
pub B: bitcoin::PublicKey,
pub S_b_monero: monero::PublicKey,
pub S_b_bitcoin: bitcoin::PublicKey,
pub dleq_proof_s_b: CrossCurveDLEQProof,
pub v_b: monero::PrivateViewKey,
#[serde(with = "swap_serde::bitcoin::address_serde")]
pub refund_address: bitcoin::Address,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
pub tx_refund_fee: bitcoin::Amount,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
pub tx_cancel_fee: bitcoin::Amount,
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message1 {
pub A: bitcoin::PublicKey,
pub S_a_monero: monero::PublicKey,
pub S_a_bitcoin: bitcoin::PublicKey,
pub dleq_proof_s_a: CrossCurveDLEQProof,
pub v_a: monero::PrivateViewKey,
#[serde(with = "swap_serde::bitcoin::address_serde")]
pub redeem_address: bitcoin::Address,
#[serde(with = "swap_serde::bitcoin::address_serde")]
pub punish_address: bitcoin::Address,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
pub tx_redeem_fee: bitcoin::Amount,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
pub tx_punish_fee: bitcoin::Amount,
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message2 {
pub psbt: bitcoin::PartiallySignedTransaction,
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message3 {
pub tx_cancel_sig: bitcoin::Signature,
pub tx_refund_encsig: bitcoin::EncryptedSignature,
}
#[allow(non_snake_case)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message4 {
pub tx_punish_sig: bitcoin::Signature,
pub tx_cancel_sig: bitcoin::Signature,
pub tx_early_refund_sig: bitcoin::Signature,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq)]
pub enum State {
Alice(AliceState),
Bob(BobState),
}
impl State {
pub fn swap_finished(&self) -> bool {
match self {
State::Alice(state) => alice_is_complete(state),
State::Bob(state) => bob_is_complete(state),
}
}
}
impl From<AliceState> for State {
fn from(alice: AliceState) -> Self {
Self::Alice(alice)
}
}
impl From<BobState> for State {
fn from(bob: BobState) -> Self {
Self::Bob(bob)
}
}
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("Not in the role of Alice")]
pub struct NotAlice;
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("Not in the role of Bob")]
pub struct NotBob;
impl TryInto<BobState> for State {
type Error = NotBob;
fn try_into(self) -> std::result::Result<BobState, Self::Error> {
match self {
State::Alice(_) => Err(NotBob),
State::Bob(state) => Ok(state),
}
}
}
impl TryInto<AliceState> for State {
type Error = NotAlice;
fn try_into(self) -> std::result::Result<AliceState, Self::Error> {
match self {
State::Alice(state) => Ok(state),
State::Bob(_) => Err(NotAlice),
}
}
}
#[async_trait]
pub trait Database {
async fn insert_peer_id(&self, swap_id: Uuid, peer_id: PeerId) -> Result<()>;
async fn get_peer_id(&self, swap_id: Uuid) -> Result<PeerId>;
async fn insert_monero_address_pool(
&self,
swap_id: Uuid,
address: MoneroAddressPool,
) -> Result<()>;
async fn get_monero_address_pool(&self, swap_id: Uuid) -> Result<MoneroAddressPool>;
async fn get_monero_addresses(&self) -> Result<Vec<::monero::Address>>;
async fn insert_address(&self, peer_id: PeerId, address: Multiaddr) -> Result<()>;
async fn get_addresses(&self, peer_id: PeerId) -> Result<Vec<Multiaddr>>;
async fn get_all_peer_addresses(&self) -> Result<Vec<(PeerId, Vec<Multiaddr>)>>;
async fn get_swap_start_date(&self, swap_id: Uuid) -> Result<String>;
async fn insert_latest_state(&self, swap_id: Uuid, state: State) -> Result<()>;
async fn get_state(&self, swap_id: Uuid) -> Result<State>;
async fn get_states(&self, swap_id: Uuid) -> Result<Vec<State>>;
async fn all(&self) -> Result<Vec<(Uuid, State)>>;
async fn insert_buffered_transfer_proof(
&self,
swap_id: Uuid,
proof: monero::TransferProof,
) -> Result<()>;
async fn get_buffered_transfer_proof(
&self,
swap_id: Uuid,
) -> Result<Option<monero::TransferProof>>;
}

3
swap-machine/src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod alice;
pub mod bob;
pub mod common;

View file

@ -8,7 +8,6 @@ name = "orchestrator"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
bitcoin = { workspace = true }
chrono = "0.4.41"
compose_spec = "0.3.0"
@ -18,6 +17,7 @@ serde_yaml = "0.9.34"
swap-env = { path = "../swap-env" }
toml = { workspace = true }
url = { workspace = true }
anyhow = { workspace = true }
[build-dependencies]
anyhow = { workspace = true }

View file

@ -4,8 +4,8 @@ mod images;
mod prompt;
use crate::compose::{
IntoSpec, OrchestratorDirectories, OrchestratorImage, OrchestratorImages, OrchestratorInput,
OrchestratorNetworks, ASB_DATA_DIR, DOCKER_COMPOSE_FILE,
ASB_DATA_DIR, DOCKER_COMPOSE_FILE, IntoSpec, OrchestratorDirectories, OrchestratorImage,
OrchestratorImages, OrchestratorInput, OrchestratorNetworks,
};
use std::path::PathBuf;
use swap_env::config::{

View file

@ -1,4 +1,4 @@
use dialoguer::{theme::ColorfulTheme, Select};
use dialoguer::{Select, theme::ColorfulTheme};
use swap_env::prompt as config_prompt;
use url::Url;

12
swap-proptest/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "swap-proptest"
version = "0.1.0"
edition = "2024"
[dependencies]
bitcoin = { workspace = true }
ecdsa_fun = { workspace = true }
proptest = { workspace = true }
[lints]
workspace = true

View file

@ -1,6 +1,6 @@
pub mod urls {
use serde::de::Unexpected;
use serde::{de, Deserialize, Deserializer};
use serde::{Deserialize, Deserializer, de};
use serde_json::Value;
use url::Url;

View file

@ -1,7 +1,7 @@
pub mod multiaddresses {
use libp2p::Multiaddr;
use serde::de::Unexpected;
use serde::{de, Deserialize, Deserializer};
use serde::{Deserialize, Deserializer, de};
use serde_json::Value;
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Multiaddr>, D::Error>

View file

@ -12,11 +12,11 @@ pub enum network {
pub mod private_key {
use hex;
use monero::consensus::{Decodable, Encodable};
use monero::PrivateKey;
use monero::consensus::{Decodable, Encodable};
use serde::de::Visitor;
use serde::ser::Error;
use serde::{de, Deserializer, Serializer};
use serde::{Deserializer, Serializer, de};
use std::fmt;
use std::io::Cursor;
@ -100,7 +100,7 @@ pub mod amount {
}
pub mod address {
use anyhow::{bail, Context, Result};
use anyhow::{Context, Result, bail};
use std::str::FromStr;
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq)]

View file

@ -22,23 +22,24 @@ bdk_wallet = { workspace = true, features = ["rusqlite", "test-utils"] }
anyhow = { workspace = true }
arti-client = { workspace = true, features = ["static-sqlite", "tokio", "rustls", "onion-service-service"] }
async-compression = { version = "0.3", features = ["bzip2", "tokio"] }
async-trait = "0.1"
async-trait = { workspace = true }
asynchronous-codec = "0.7.0"
atty = "0.2"
backoff = { workspace = true }
base64 = "0.22"
big-bytes = "1"
bitcoin = { workspace = true }
bitcoin-wallet = { path = "../bitcoin-wallet" }
bmrng = "0.5.2"
comfy-table = "7.1"
config = { version = "0.14", default-features = false, features = ["toml"] }
conquer-once = "0.4"
curve25519-dalek = { package = "curve25519-dalek-ng", version = "4" }
curve25519-dalek = { workspace = true }
data-encoding = "2.6"
derive_builder = "0.20.2"
dfx-swiss-sdk = { workspace = true, optional = true }
dialoguer = "0.11"
ecdsa_fun = { version = "0.10", default-features = false, features = ["libsecp_compat", "serde", "adaptor"] }
ecdsa_fun = { workspace = true, features = ["libsecp_compat", "serde", "adaptor"] }
ed25519-dalek = "1"
electrum-pool = { path = "../electrum-pool" }
fns = "0.0.7"
@ -57,7 +58,7 @@ once_cell = { workspace = true }
pem = "3.0"
proptest = "1"
rand = { workspace = true }
rand_chacha = "0.3"
rand_chacha = { workspace = true }
regex = "1.10"
reqwest = { workspace = true, features = ["http2", "rustls-tls-native-roots", "stream", "socks"] }
rust_decimal = { version = "1", features = ["serde-float"] }
@ -68,15 +69,17 @@ serde = { workspace = true }
serde_cbor = "0.11"
serde_json = { workspace = true }
serde_with = { version = "1", features = ["macros"] }
sha2 = "0.10"
sigma_fun = { version = "0.7", default-features = false, features = ["ed25519", "serde", "secp256k1", "alloc"] }
sha2 = { workspace = true }
sigma_fun = { workspace = true, default-features = false, features = ["ed25519", "serde", "secp256k1", "alloc"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls"] }
structopt = "0.3"
strum = { version = "0.26", features = ["derive"] }
swap-controller-api = { path = "../swap-controller-api" }
swap-core = { path = "../swap-core" }
swap-env = { path = "../swap-env" }
swap-feed = { path = "../swap-feed" }
swap-fs = { path = "../swap-fs" }
swap-machine = { path = "../swap-machine" }
swap-serde = { path = "../swap-serde" }
tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false }
thiserror = { workspace = true }

View file

@ -2,6 +2,7 @@ use self::quote::{
make_quote, unlocked_monero_balance_with_timeout, QuoteCacheKey, QUOTE_CACHE_TTL,
};
use crate::asb::{Behaviour, OutEvent};
use crate::monero;
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
use crate::network::quote::BidQuote;
@ -10,8 +11,8 @@ use crate::network::transfer_proof;
use crate::protocol::alice::swap::has_already_processed_enc_sig;
use crate::protocol::alice::{AliceState, State3, Swap, TipConfig};
use crate::protocol::{Database, State};
use crate::{bitcoin, monero};
use anyhow::{anyhow, Context, Result};
use bitcoin_wallet::BitcoinWallet;
use futures::future;
use futures::future::{BoxFuture, FutureExt};
use futures::stream::{FuturesUnordered, StreamExt};
@ -26,6 +27,7 @@ use std::fmt::Debug;
use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
use swap_core::bitcoin;
use swap_env::env;
use swap_feed::LatestRate;
use tokio::fs::{write, File};
@ -41,7 +43,7 @@ where
{
swarm: libp2p::Swarm<Behaviour<LR>>,
env_config: env::Config,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
db: Arc<dyn Database + Send + Sync>,
latest_rate: LR,
@ -132,7 +134,7 @@ where
pub fn new(
swarm: Swarm<Behaviour<LR>>,
env_config: env::Config,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
db: Arc<dyn Database + Send + Sync>,
latest_rate: LR,
@ -245,7 +247,7 @@ where
}
};
let wallet_snapshot = match WalletSnapshot::capture(&self.bitcoin_wallet, &self.monero_wallet, &self.external_redeem_address, btc).await {
let wallet_snapshot = match WalletSnapshot::capture(self.bitcoin_wallet.clone(), &self.monero_wallet, &self.external_redeem_address, btc).await {
Ok(wallet_snapshot) => wallet_snapshot,
Err(error) => {
tracing::error!("Swap request will be ignored because we were unable to create wallet snapshot for swap: {:#}", error);
@ -1188,7 +1190,7 @@ mod tests {
rate.clone(),
|| async { Ok(balance) },
|| async { Ok(reserved_items) },
None,
Decimal::ZERO,
)
.await
.unwrap();

View file

@ -1,9 +1,10 @@
use crate::bitcoin::{self, Txid};
use crate::protocol::alice::AliceState;
use crate::protocol::Database;
use anyhow::{bail, Result};
use bitcoin_wallet::BitcoinWallet;
use std::convert::TryInto;
use std::sync::Arc;
use swap_core::bitcoin::Txid;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@ -14,7 +15,7 @@ pub enum Error {
pub async fn punish(
swap_id: Uuid,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
db: Arc<dyn Database>,
) -> Result<(Txid, AliceState)> {
let state = db.get_state(swap_id).await?.try_into()?;
@ -47,7 +48,7 @@ pub async fn punish(
tracing::info!(%swap_id, "Trying to manually punish swap");
let txid = state3.punish_btc(&bitcoin_wallet).await?;
let txid = state3.punish_btc(bitcoin_wallet.as_ref()).await?;
let state = AliceState::BtcPunished {
state3: state3.clone(),

View file

@ -1,9 +1,10 @@
use crate::bitcoin::{Txid, Wallet};
use crate::bitcoin::Wallet;
use crate::protocol::alice::AliceState;
use crate::protocol::Database;
use anyhow::{bail, Result};
use std::convert::TryInto;
use std::sync::Arc;
use swap_core::bitcoin::Txid;
use uuid::Uuid;
pub enum Finality {
@ -60,7 +61,9 @@ pub async fn redeem(
Ok((txid, state))
}
AliceState::BtcRedeemTransactionPublished { state3, .. } => {
let subscription = bitcoin_wallet.subscribe_to(state3.tx_redeem()).await;
let subscription = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_redeem()))
.await;
if let Finality::Await = finality {
subscription.wait_until_final().await?;

View file

@ -1,9 +1,10 @@
use crate::bitcoin::{self};
use crate::common::retry;
use crate::monero;
use crate::protocol::alice::swap::XmrRefundable;
use crate::protocol::alice::AliceState;
use crate::protocol::Database;
use anyhow::{bail, Result};
use bitcoin_wallet::BitcoinWallet;
use libp2p::PeerId;
use std::convert::TryInto;
use std::sync::Arc;
@ -27,7 +28,7 @@ pub enum Error {
pub async fn refund(
swap_id: Uuid,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
db: Arc<dyn Database>,
) -> Result<AliceState> {

View file

@ -1,7 +1,8 @@
use crate::asb::event_loop::EventLoopService;
use crate::monero;
use crate::protocol::Database;
use crate::{bitcoin, monero};
use anyhow::{Context, Result};
use bitcoin_wallet::BitcoinWallet;
use jsonrpsee::server::{ServerBuilder, ServerHandle};
use jsonrpsee::types::error::ErrorCode;
use jsonrpsee::types::ErrorObjectOwned;
@ -20,7 +21,7 @@ impl RpcServer {
pub async fn start(
host: String,
port: u16,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
event_loop_service: EventLoopService,
db: Arc<dyn Database + Send + Sync>,
@ -54,7 +55,7 @@ impl RpcServer {
}
pub struct RpcImpl {
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
event_loop_service: EventLoopService,
db: Arc<dyn Database + Send + Sync>,

View file

@ -1,788 +1,3 @@
pub mod wallet;
mod cancel;
mod early_refund;
mod lock;
mod punish;
mod redeem;
mod refund;
mod timelocks;
pub use crate::bitcoin::cancel::{CancelTimelock, PunishTimelock, TxCancel};
pub use crate::bitcoin::early_refund::TxEarlyRefund;
pub use crate::bitcoin::lock::TxLock;
pub use crate::bitcoin::punish::TxPunish;
pub use crate::bitcoin::redeem::TxRedeem;
pub use crate::bitcoin::refund::TxRefund;
pub use crate::bitcoin::timelocks::{BlockHeight, ExpiredTimelocks};
pub use ::bitcoin::amount::Amount;
pub use ::bitcoin::psbt::Psbt as PartiallySignedTransaction;
pub use ::bitcoin::{Address, AddressType, Network, Transaction, Txid};
pub use ecdsa_fun::adaptor::EncryptedSignature;
pub use ecdsa_fun::fun::Scalar;
pub use ecdsa_fun::Signature;
pub use wallet::Wallet;
#[cfg(test)]
pub use wallet::TestWalletBuilder;
use crate::bitcoin::wallet::ScriptStatus;
use ::bitcoin::hashes::Hash;
use ::bitcoin::secp256k1::ecdsa;
use ::bitcoin::sighash::SegwitV0Sighash as Sighash;
use anyhow::{bail, Context, Result};
use bdk_wallet::miniscript::descriptor::Wsh;
use bdk_wallet::miniscript::{Descriptor, Segwitv0};
use ecdsa_fun::adaptor::{Adaptor, HashTranscript};
use ecdsa_fun::fun::Point;
use ecdsa_fun::nonce::Deterministic;
use ecdsa_fun::ECDSA;
use rand::{CryptoRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::str::FromStr;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct SecretKey {
inner: Scalar,
public: Point,
}
impl SecretKey {
pub fn new_random<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
let scalar = Scalar::random(rng);
let ecdsa = ECDSA::<()>::default();
let public = ecdsa.verification_key_for(&scalar);
Self {
inner: scalar,
public,
}
}
pub fn public(&self) -> PublicKey {
PublicKey(self.public)
}
pub fn to_bytes(&self) -> [u8; 32] {
self.inner.to_bytes()
}
pub fn sign(&self, digest: Sighash) -> Signature {
let ecdsa = ECDSA::<Deterministic<Sha256>>::default();
ecdsa.sign(&self.inner, &digest.to_byte_array())
}
// TxRefund encsigning explanation:
//
// A and B, are the Bitcoin Public Keys which go on the joint output for
// TxLock_Bitcoin. S_a and S_b, are the Monero Public Keys which go on the
// joint output for TxLock_Monero
// tx_refund: multisig(A, B), published by bob
// bob can produce sig on B using b
// alice sends over an encrypted signature on A encrypted with S_b
// s_b is leaked to alice when bob publishes signed tx_refund allowing her to
// recover s_b: recover(encsig, S_b, sig_tx_refund) = s_b
// alice now has s_a and s_b and can refund monero
// self = a, Y = S_b, digest = tx_refund
pub fn encsign(&self, Y: PublicKey, digest: Sighash) -> EncryptedSignature {
let adaptor = Adaptor::<
HashTranscript<Sha256, rand_chacha::ChaCha20Rng>,
Deterministic<Sha256>,
>::default();
adaptor.encrypted_sign(&self.inner, &Y.0, &digest.to_byte_array())
}
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublicKey(Point);
impl PublicKey {
#[cfg(test)]
pub fn random() -> Self {
Self(Point::random(&mut rand::thread_rng()))
}
}
impl From<PublicKey> for Point {
fn from(from: PublicKey) -> Self {
from.0
}
}
impl TryFrom<PublicKey> for bitcoin::PublicKey {
type Error = bitcoin::key::FromSliceError;
fn try_from(pubkey: PublicKey) -> Result<Self, Self::Error> {
let bytes = pubkey.0.to_bytes();
bitcoin::PublicKey::from_slice(&bytes)
}
}
impl From<Point> for PublicKey {
fn from(p: Point) -> Self {
Self(p)
}
}
impl From<Scalar> for SecretKey {
fn from(scalar: Scalar) -> Self {
let ecdsa = ECDSA::<()>::default();
let public = ecdsa.verification_key_for(&scalar);
Self {
inner: scalar,
public,
}
}
}
impl From<SecretKey> for Scalar {
fn from(sk: SecretKey) -> Self {
sk.inner
}
}
impl From<Scalar> for PublicKey {
fn from(scalar: Scalar) -> Self {
let ecdsa = ECDSA::<()>::default();
PublicKey(ecdsa.verification_key_for(&scalar))
}
}
pub fn verify_sig(
verification_key: &PublicKey,
transaction_sighash: &Sighash,
sig: &Signature,
) -> Result<()> {
let ecdsa = ECDSA::verify_only();
if ecdsa.verify(
&verification_key.0,
&transaction_sighash.to_byte_array(),
sig,
) {
Ok(())
} else {
bail!(InvalidSignature)
}
}
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[error("signature is invalid")]
pub struct InvalidSignature;
pub fn verify_encsig(
verification_key: PublicKey,
encryption_key: PublicKey,
digest: &Sighash,
encsig: &EncryptedSignature,
) -> Result<()> {
let adaptor = Adaptor::<HashTranscript<Sha256>, Deterministic<Sha256>>::default();
if adaptor.verify_encrypted_signature(
&verification_key.0,
&encryption_key.0,
&digest.to_byte_array(),
encsig,
) {
Ok(())
} else {
bail!(InvalidEncryptedSignature)
}
}
#[derive(Clone, Copy, Debug, thiserror::Error)]
#[error("encrypted signature is invalid")]
pub struct InvalidEncryptedSignature;
pub fn build_shared_output_descriptor(
A: Point,
B: Point,
) -> Result<Descriptor<bitcoin::PublicKey>> {
const MINISCRIPT_TEMPLATE: &str = "c:and_v(v:pk(A),pk_k(B))";
let miniscript = MINISCRIPT_TEMPLATE
.replace('A', &A.to_string())
.replace('B', &B.to_string());
let miniscript =
bdk_wallet::miniscript::Miniscript::<bitcoin::PublicKey, Segwitv0>::from_str(&miniscript)
.expect("a valid miniscript");
Ok(Descriptor::Wsh(Wsh::new(miniscript)?))
}
pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Result<SecretKey> {
let adaptor = Adaptor::<HashTranscript<Sha256>, Deterministic<Sha256>>::default();
let s = adaptor
.recover_decryption_key(&S.0, &sig, &encsig)
.map(SecretKey::from)
.context("Failed to recover secret from adaptor signature")?;
Ok(s)
}
pub fn current_epoch(
cancel_timelock: CancelTimelock,
punish_timelock: PunishTimelock,
tx_lock_status: ScriptStatus,
tx_cancel_status: ScriptStatus,
) -> ExpiredTimelocks {
if tx_cancel_status.is_confirmed_with(punish_timelock) {
return ExpiredTimelocks::Punish;
}
if tx_lock_status.is_confirmed_with(cancel_timelock) {
return ExpiredTimelocks::Cancel {
blocks_left: tx_cancel_status.blocks_left_until(punish_timelock),
};
}
ExpiredTimelocks::None {
blocks_left: tx_lock_status.blocks_left_until(cancel_timelock),
}
}
pub mod bitcoin_address {
use anyhow::{Context, Result};
use bitcoin::{
address::{NetworkChecked, NetworkUnchecked},
Address,
};
use serde::Serialize;
use std::str::FromStr;
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Serialize)]
#[error("Invalid Bitcoin address provided, expected address on network {expected:?} but address provided is on {actual:?}")]
pub struct BitcoinAddressNetworkMismatch {
#[serde(with = "swap_serde::bitcoin::network")]
expected: bitcoin::Network,
#[serde(with = "swap_serde::bitcoin::network")]
actual: bitcoin::Network,
}
pub fn parse(addr_str: &str) -> Result<bitcoin::Address<NetworkUnchecked>> {
let address = bitcoin::Address::from_str(addr_str)?;
if address.assume_checked_ref().address_type() != Some(bitcoin::AddressType::P2wpkh) {
anyhow::bail!("Invalid Bitcoin address provided, only bech32 format is supported!")
}
Ok(address)
}
/// Parse the address and validate the network.
pub fn parse_and_validate_network(
address: &str,
expected_network: bitcoin::Network,
) -> Result<bitcoin::Address> {
let addres = bitcoin::Address::from_str(address)?;
let addres = addres.require_network(expected_network).with_context(|| {
format!("Bitcoin address network mismatch, expected `{expected_network:?}`")
})?;
Ok(addres)
}
/// Parse the address and validate the network.
pub fn parse_and_validate(address: &str, is_testnet: bool) -> Result<bitcoin::Address> {
let expected_network = if is_testnet {
bitcoin::Network::Testnet
} else {
bitcoin::Network::Bitcoin
};
parse_and_validate_network(address, expected_network)
}
/// Validate the address network.
pub fn validate(
address: Address<NetworkUnchecked>,
is_testnet: bool,
) -> Result<Address<NetworkChecked>> {
let expected_network = if is_testnet {
bitcoin::Network::Testnet
} else {
bitcoin::Network::Bitcoin
};
validate_network(address, expected_network)
}
/// Validate the address network.
pub fn validate_network(
address: Address<NetworkUnchecked>,
expected_network: bitcoin::Network,
) -> Result<Address<NetworkChecked>> {
address
.require_network(expected_network)
.context("Bitcoin address network mismatch")
}
/// Validate the address network even though the address is already checked.
pub fn revalidate_network(
address: Address,
expected_network: bitcoin::Network,
) -> Result<Address> {
address
.as_unchecked()
.clone()
.require_network(expected_network)
.context("bitcoin address network mismatch")
}
/// Validate the address network even though the address is already checked.
pub fn revalidate(address: Address, is_testnet: bool) -> Result<Address> {
revalidate_network(
address,
if is_testnet {
bitcoin::Network::Testnet
} else {
bitcoin::Network::Bitcoin
},
)
}
}
// Transform the ecdsa der signature bytes into a secp256kfun ecdsa signature type.
pub fn extract_ecdsa_sig(sig: &[u8]) -> Result<Signature> {
let data = &sig[..sig.len() - 1];
let sig = ecdsa::Signature::from_der(data)?.serialize_compact();
Signature::from_bytes(sig).ok_or(anyhow::anyhow!("invalid signature"))
}
/// Bitcoin error codes: https://github.com/bitcoin/bitcoin/blob/97d3500601c1d28642347d014a6de1e38f53ae4e/src/rpc/protocol.h#L23
pub enum RpcErrorCode {
/// Transaction or block was rejected by network rules. Error code -26.
RpcVerifyRejected,
/// Transaction or block was rejected by network rules. Error code -27.
RpcVerifyAlreadyInChain,
/// General error during transaction or block submission
RpcVerifyError,
/// Invalid address or key. Error code -5. Is throwns when a transaction is not found.
/// See:
/// - https://github.com/bitcoin/bitcoin/blob/ae024137bda9fe189f4e7ccf26dbaffd44cbbeb6/src/rpc/mempool.cpp#L470-L472
/// - https://github.com/bitcoin/bitcoin/blob/ae024137bda9fe189f4e7ccf26dbaffd44cbbeb6/src/rpc/rawtransaction.cpp#L352-L368
RpcInvalidAddressOrKey,
}
impl From<RpcErrorCode> for i64 {
fn from(code: RpcErrorCode) -> Self {
match code {
RpcErrorCode::RpcVerifyError => -25,
RpcErrorCode::RpcVerifyRejected => -26,
RpcErrorCode::RpcVerifyAlreadyInChain => -27,
RpcErrorCode::RpcInvalidAddressOrKey => -5,
}
}
}
pub fn parse_rpc_error_code(error: &anyhow::Error) -> anyhow::Result<i64> {
// First try to extract an Electrum error from a MultiError if present
if let Some(multi_error) = error.downcast_ref::<electrum_pool::MultiError>() {
// Try to find the first Electrum error in the MultiError
for single_error in multi_error.iter() {
if let bdk_electrum::electrum_client::Error::Protocol(serde_json::Value::String(
string,
)) = single_error
{
let json = serde_json::from_str(
&string
.replace("sendrawtransaction RPC error:", "")
.replace("daemon error:", ""),
)?;
let json_map = match json {
serde_json::Value::Object(map) => map,
_ => continue, // Try next error if this one isn't a JSON object
};
let error_code_value = match json_map.get("code") {
Some(val) => val,
None => continue, // Try next error if no error code field
};
let error_code_number = match error_code_value {
serde_json::Value::Number(num) => num,
_ => continue, // Try next error if error code isn't a number
};
if let Some(int) = error_code_number.as_i64() {
return Ok(int);
}
}
}
// If we couldn't extract an RPC error code from any error in the MultiError
bail!(
"Error is of incorrect variant. We expected an Electrum error, but got: {}",
error
);
}
// Original logic for direct Electrum errors
let string = match error.downcast_ref::<bdk_electrum::electrum_client::Error>() {
Some(bdk_electrum::electrum_client::Error::Protocol(serde_json::Value::String(string))) => {
string
}
_ => bail!(
"Error is of incorrect variant. We expected an Electrum error, but got: {}",
error
),
};
let json = serde_json::from_str(
&string
.replace("sendrawtransaction RPC error:", "")
.replace("daemon error:", ""),
)?;
let json_map = match json {
serde_json::Value::Object(map) => map,
_ => bail!("Json error is not json object "),
};
let error_code_value = match json_map.get("code") {
Some(val) => val,
None => bail!("No error code field"),
};
let error_code_number = match error_code_value {
serde_json::Value::Number(num) => num,
_ => bail!("Error code is not a number"),
};
if let Some(int) = error_code_number.as_i64() {
Ok(int)
} else {
bail!("Error code is not an unsigned integer")
}
}
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("transaction does not spend anything")]
pub struct NoInputs;
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("transaction has {0} inputs, expected 1")]
pub struct TooManyInputs(usize);
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("empty witness stack")]
pub struct EmptyWitnessStack;
#[derive(Clone, Copy, thiserror::Error, Debug)]
#[error("input has {0} witnesses, expected 3")]
pub struct NotThreeWitnesses(usize);
#[cfg(test)]
mod tests {
use super::*;
use crate::monero::TransferProof;
use crate::protocol::{alice, bob};
use bitcoin::secp256k1;
use curve25519_dalek::scalar::Scalar;
use ecdsa_fun::fun::marker::{NonZero, Public};
use monero::PrivateKey;
use rand::rngs::OsRng;
use std::matches;
use swap_env::env::{GetConfig, Regtest};
use uuid::Uuid;
#[test]
fn lock_confirmations_le_to_cancel_timelock_no_timelock_expired() {
let tx_lock_status = ScriptStatus::from_confirmations(4);
let tx_cancel_status = ScriptStatus::Unseen;
let expired_timelock = current_epoch(
CancelTimelock::new(5),
PunishTimelock::new(5),
tx_lock_status,
tx_cancel_status,
);
assert!(matches!(expired_timelock, ExpiredTimelocks::None { .. }));
}
#[test]
fn lock_confirmations_ge_to_cancel_timelock_cancel_timelock_expired() {
let tx_lock_status = ScriptStatus::from_confirmations(5);
let tx_cancel_status = ScriptStatus::Unseen;
let expired_timelock = current_epoch(
CancelTimelock::new(5),
PunishTimelock::new(5),
tx_lock_status,
tx_cancel_status,
);
assert!(matches!(expired_timelock, ExpiredTimelocks::Cancel { .. }));
}
#[test]
fn cancel_confirmations_ge_to_punish_timelock_punish_timelock_expired() {
let tx_lock_status = ScriptStatus::from_confirmations(10);
let tx_cancel_status = ScriptStatus::from_confirmations(5);
let expired_timelock = current_epoch(
CancelTimelock::new(5),
PunishTimelock::new(5),
tx_lock_status,
tx_cancel_status,
);
assert_eq!(expired_timelock, ExpiredTimelocks::Punish)
}
#[tokio::test]
async fn calculate_transaction_weights() {
let alice_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let bob_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let spending_fee = Amount::from_sat(1_000);
let btc_amount = Amount::from_sat(500_000);
let xmr_amount = crate::monero::Amount::from_piconero(10000);
let tx_redeem_fee = alice_wallet
.estimate_fee(TxRedeem::weight(), Some(btc_amount))
.await
.unwrap();
let tx_punish_fee = alice_wallet
.estimate_fee(TxPunish::weight(), Some(btc_amount))
.await
.unwrap();
let tx_lock_fee = alice_wallet
.estimate_fee(TxLock::weight(), Some(btc_amount))
.await
.unwrap();
let redeem_address = alice_wallet.new_address().await.unwrap();
let punish_address = alice_wallet.new_address().await.unwrap();
let config = Regtest::get_config();
let alice_state0 = alice::State0::new(
btc_amount,
xmr_amount,
config,
redeem_address,
punish_address,
tx_redeem_fee,
tx_punish_fee,
&mut OsRng,
);
let bob_state0 = bob::State0::new(
Uuid::new_v4(),
&mut OsRng,
btc_amount,
xmr_amount,
config.bitcoin_cancel_timelock.into(),
config.bitcoin_punish_timelock.into(),
bob_wallet.new_address().await.unwrap(),
config.monero_finality_confirmations,
spending_fee,
spending_fee,
tx_lock_fee,
);
let message0 = bob_state0.next_message();
let (_, alice_state1) = alice_state0.receive(message0).unwrap();
let alice_message1 = alice_state1.next_message();
let bob_state1 = bob_state0
.receive(&bob_wallet, alice_message1)
.await
.unwrap();
let bob_message2 = bob_state1.next_message();
let alice_state2 = alice_state1.receive(bob_message2).unwrap();
let alice_message3 = alice_state2.next_message();
let bob_state2 = bob_state1.receive(alice_message3).unwrap();
let bob_message4 = bob_state2.next_message();
let alice_state3 = alice_state2.receive(bob_message4).unwrap();
let (bob_state3, _tx_lock) = bob_state2.lock_btc().await.unwrap();
let bob_state4 = bob_state3.xmr_locked(
crate::monero::BlockHeight { height: 0 },
// We use bogus values here, because they're irrelevant to this test
TransferProof::new(
crate::monero::TxHash("foo".into()),
PrivateKey::from_scalar(Scalar::one()),
),
);
let encrypted_signature = bob_state4.tx_redeem_encsig();
let bob_state6 = bob_state4.cancel();
let cancel_transaction = alice_state3.signed_cancel_transaction().unwrap();
let punish_transaction = alice_state3.signed_punish_transaction().unwrap();
let redeem_transaction = alice_state3
.signed_redeem_transaction(encrypted_signature)
.unwrap();
let refund_transaction = bob_state6.signed_refund_transaction().unwrap();
assert_weight(redeem_transaction, TxRedeem::weight().to_wu(), "TxRedeem");
assert_weight(cancel_transaction, TxCancel::weight().to_wu(), "TxCancel");
assert_weight(punish_transaction, TxPunish::weight().to_wu(), "TxPunish");
assert_weight(refund_transaction, TxRefund::weight().to_wu(), "TxRefund");
// Test TxEarlyRefund transaction
let early_refund_transaction = alice_state3
.signed_early_refund_transaction()
.unwrap()
.unwrap();
assert_weight(
early_refund_transaction,
TxEarlyRefund::weight() as u64,
"TxEarlyRefund",
);
}
#[tokio::test]
async fn tx_early_refund_can_be_constructed_and_signed() {
let alice_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let bob_wallet = TestWalletBuilder::new(Amount::ONE_BTC.to_sat())
.build()
.await;
let spending_fee = Amount::from_sat(1_000);
let btc_amount = Amount::from_sat(500_000);
let xmr_amount = crate::monero::Amount::from_piconero(10000);
let tx_redeem_fee = alice_wallet
.estimate_fee(TxRedeem::weight(), Some(btc_amount))
.await
.unwrap();
let tx_punish_fee = alice_wallet
.estimate_fee(TxPunish::weight(), Some(btc_amount))
.await
.unwrap();
let refund_address = alice_wallet.new_address().await.unwrap();
let punish_address = alice_wallet.new_address().await.unwrap();
let config = Regtest::get_config();
let alice_state0 = alice::State0::new(
btc_amount,
xmr_amount,
config,
refund_address.clone(),
punish_address,
tx_redeem_fee,
tx_punish_fee,
&mut OsRng,
);
let bob_state0 = bob::State0::new(
Uuid::new_v4(),
&mut OsRng,
btc_amount,
xmr_amount,
config.bitcoin_cancel_timelock.into(),
config.bitcoin_punish_timelock.into(),
bob_wallet.new_address().await.unwrap(),
config.monero_finality_confirmations,
spending_fee,
spending_fee,
spending_fee,
);
// Complete the state machine up to State3
let message0 = bob_state0.next_message();
let (_, alice_state1) = alice_state0.receive(message0).unwrap();
let alice_message1 = alice_state1.next_message();
let bob_state1 = bob_state0
.receive(&bob_wallet, alice_message1)
.await
.unwrap();
let bob_message2 = bob_state1.next_message();
let alice_state2 = alice_state1.receive(bob_message2).unwrap();
let alice_message3 = alice_state2.next_message();
let bob_state2 = bob_state1.receive(alice_message3).unwrap();
let bob_message4 = bob_state2.next_message();
let alice_state3 = alice_state2.receive(bob_message4).unwrap();
// Test TxEarlyRefund construction
let tx_early_refund = alice_state3.tx_early_refund();
// Verify basic properties
assert_eq!(tx_early_refund.txid(), tx_early_refund.txid()); // Should be deterministic
assert!(tx_early_refund.digest() != Sighash::all_zeros()); // Should have valid digest
// Test that it can be signed and completed
let early_refund_transaction = alice_state3
.signed_early_refund_transaction()
.unwrap()
.unwrap();
// Verify the transaction has expected structure
assert_eq!(early_refund_transaction.input.len(), 1); // One input from lock tx
assert_eq!(early_refund_transaction.output.len(), 1); // One output to refund address
assert_eq!(
early_refund_transaction.output[0].script_pubkey,
refund_address.script_pubkey()
);
// Verify the input is spending the lock transaction
assert_eq!(
early_refund_transaction.input[0].previous_output,
alice_state3.tx_lock.as_outpoint()
);
// Verify the amount is correct (lock amount minus fee)
let expected_amount = alice_state3.tx_lock.lock_amount() - alice_state3.tx_refund_fee;
assert_eq!(early_refund_transaction.output[0].value, expected_amount);
}
#[test]
fn tx_early_refund_has_correct_weight() {
// TxEarlyRefund should have the same weight as other similar transactions
assert_eq!(TxEarlyRefund::weight(), 548);
// It should be the same as TxRedeem and TxRefund weights since they have similar structure
assert_eq!(TxEarlyRefund::weight() as u64, TxRedeem::weight().to_wu());
assert_eq!(TxEarlyRefund::weight() as u64, TxRefund::weight().to_wu());
}
// Weights fluctuate because of the length of the signatures. Valid ecdsa
// signatures can have 68, 69, 70, 71, or 72 bytes. Since most of our
// transactions have 2 signatures the weight can be up to 8 bytes less than
// the static weight (4 bytes per signature).
fn assert_weight(transaction: Transaction, expected_weight: u64, tx_name: &str) {
let is_weight = transaction.weight();
assert!(
expected_weight - is_weight.to_wu() <= 8,
"{} to have weight {}, but was {}. Transaction: {:#?}",
tx_name,
expected_weight,
is_weight,
transaction
)
}
#[test]
fn compare_point_hex() {
// secp256kfun Point and secp256k1 PublicKey should have the same bytes and hex representation
let secp = secp256k1::Secp256k1::default();
let keypair = secp256k1::Keypair::new(&secp, &mut OsRng);
let pubkey = keypair.public_key();
let point: Point<_, Public, NonZero> = Point::from_bytes(pubkey.serialize()).unwrap();
assert_eq!(pubkey.to_string(), point.to_string());
}
}
pub use swap_core::bitcoin::*;
pub use wallet::*;

View file

@ -21,12 +21,14 @@ use bdk_wallet::{Balance, PersistedWallet};
use bitcoin::bip32::Xpriv;
use bitcoin::{psbt::Psbt as PartiallySignedTransaction, Txid};
use bitcoin::{ScriptBuf, Weight};
use derive_builder::Builder;
use electrum_pool::ElectrumBalancer;
use moka;
use rust_decimal::prelude::*;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt;
use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf;
@ -34,17 +36,13 @@ use std::sync::Arc;
use std::sync::Mutex as SyncMutex;
use std::time::Duration;
use std::time::Instant;
use swap_core::bitcoin::bitcoin_address::revalidate_network;
use swap_core::bitcoin::BlockHeight;
use sync_ext::{CumulativeProgressHandle, InnerSyncCallback, SyncCallbackExt};
use tokio::sync::watch;
use tokio::sync::Mutex as TokioMutex;
use tracing::{debug_span, Instrument};
use super::bitcoin_address::revalidate_network;
use super::BlockHeight;
use derive_builder::Builder;
use electrum_pool::ElectrumBalancer;
use moka;
/// We allow transaction fees of up to 20% of the transferred amount to ensure
/// that lock transactions can always be published, even when fees are high.
const MAX_RELATIVE_TX_FEE: Decimal = dec!(0.20);
@ -239,63 +237,9 @@ pub enum PersisterConfig {
InMemorySqlite,
}
/// A subscription to the status of a given transaction
/// that can be used to wait for the transaction to be confirmed.
#[derive(Debug, Clone)]
pub struct Subscription {
/// A receiver used to await updates to the status of the transaction.
receiver: watch::Receiver<ScriptStatus>,
/// The number of confirmations we require for a transaction to be considered final.
finality_confirmations: u32,
/// The transaction ID we are subscribing to.
txid: Txid,
}
/// The possible statuses of a script.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ScriptStatus {
Unseen,
InMempool,
Confirmed(Confirmed),
Retrying,
}
/// The status of a confirmed transaction.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Confirmed {
/// The depth of this transaction within the blockchain.
///
/// Zero if the transaction is included in the latest block.
depth: u32,
}
/// Defines a watchable transaction.
///
/// For a transaction to be watchable, we need to know two things: Its
/// transaction ID and the specific output script that is going to change.
/// A transaction can obviously have multiple outputs but our protocol purposes,
/// we are usually interested in a specific one.
pub trait Watchable {
/// The transaction ID.
fn id(&self) -> Txid;
/// The script of the output we are interested in.
fn script(&self) -> ScriptBuf;
/// Convenience method to get both the script and the txid.
fn script_and_txid(&self) -> (ScriptBuf, Txid) {
(self.script(), self.id())
}
}
/// An object that can estimate fee rates and minimum relay fees.
pub trait EstimateFeeRate {
/// Estimate the fee rate for a given target block.
fn estimate_feerate(
&self,
target_block: u32,
) -> impl std::future::Future<Output = Result<FeeRate>> + Send;
/// Get the minimum relay fee.
fn min_relay_fee(&self) -> impl std::future::Future<Output = Result<FeeRate>> + Send;
}
pub use bitcoin_wallet::primitives::{
Confirmed, EstimateFeeRate, ScriptStatus, Subscription, Watchable,
};
/// A caching wrapper around EstimateFeeRate implementations.
///
@ -725,7 +669,10 @@ impl Wallet {
// to watch for confirmations, watching a single output is enough
let subscription = self
.subscribe_to((txid, transaction.output[0].script_pubkey.clone()))
.subscribe_to(Box::new((
txid,
transaction.output[0].script_pubkey.clone(),
)))
.await;
let client = self.electrum_client.lock().await;
@ -808,10 +755,7 @@ impl Wallet {
Ok(last_tx.tx_node.txid)
}
pub async fn status_of_script<T>(&self, tx: &T) -> Result<ScriptStatus>
where
T: Watchable,
{
pub async fn status_of_script(&self, tx: &dyn Watchable) -> Result<ScriptStatus> {
self.electrum_client
.lock()
.await
@ -819,7 +763,7 @@ impl Wallet {
.await
}
pub async fn subscribe_to(&self, tx: impl Watchable + Send + Sync + 'static) -> Subscription {
pub async fn subscribe_to(&self, tx: Box<dyn Watchable>) -> Subscription {
let txid = tx.id();
let script = tx.script();
@ -1209,7 +1153,6 @@ where
electrum_rate_sat_vb = electrum_rate.to_sat_per_vb_ceil(),
mempool_space_rate_sat_vb = mempool_space_rate.to_sat_per_vb_ceil(),
"Successfully fetched fee rates from both Electrum and mempool.space. We will use the higher one"
);
Ok(std::cmp::max(electrum_rate, mempool_space_rate))
}
@ -1372,7 +1315,8 @@ where
change_override: Option<Address>,
) -> Result<PartiallySignedTransaction> {
// Check address and change address for network equality.
let address = revalidate_network(address, self.network)?;
let address =
swap_core::bitcoin::bitcoin_address::revalidate_network(address, self.network)?;
change_override
.as_ref()
@ -1431,11 +1375,14 @@ where
change_override: Option<Address>,
) -> Result<PartiallySignedTransaction> {
// Check address and change address for network equality.
let address = revalidate_network(address, self.network)?;
let address =
swap_core::bitcoin::bitcoin_address::revalidate_network(address, self.network)?;
change_override
.as_ref()
.map(|a| revalidate_network(a.clone(), self.network))
.map(|a| {
swap_core::bitcoin::bitcoin_address::revalidate_network(a.clone(), self.network)
})
.transpose()
.context("Change address is not on the correct network")?;
@ -1687,7 +1634,7 @@ impl Client {
/// As opposed to [`update_state`] this function does not
/// check the time since the last update before refreshing
/// It therefore also does not take a [`force`] parameter
pub async fn update_state_single(&mut self, script: &impl Watchable) -> Result<()> {
pub async fn update_state_single(&mut self, script: &dyn Watchable) -> Result<()> {
self.update_script_history(script).await?;
self.update_block_height().await?;
@ -1781,7 +1728,7 @@ impl Client {
}
/// Update the script history of a single script.
pub async fn update_script_history(&mut self, script: &impl Watchable) -> Result<()> {
pub async fn update_script_history(&mut self, script: &dyn Watchable) -> Result<()> {
let (script_buf, _) = script.script_and_txid();
let script_clone = script_buf.clone();
@ -1858,7 +1805,7 @@ impl Client {
/// Get the status of a script.
pub async fn status_of_script(
&mut self,
script: &impl Watchable,
script: &dyn Watchable,
force: bool,
) -> Result<ScriptStatus> {
let (script_buf, txid) = script.script_and_txid();
@ -2106,6 +2053,122 @@ impl Client {
}
}
#[derive(Clone)]
pub struct SyncRequestBuilderFactory {
chain_tip: bdk_wallet::chain::CheckPoint,
spks: Vec<((KeychainKind, u32), ScriptBuf)>,
}
impl SyncRequestBuilderFactory {
fn build(self) -> SyncRequestBuilder<(KeychainKind, u32)> {
SyncRequest::builder()
.chain_tip(self.chain_tip)
.spks_with_indexes(self.spks)
}
}
#[async_trait::async_trait]
impl bitcoin_wallet::BitcoinWallet for Wallet {
async fn balance(&self) -> Result<Amount> {
Wallet::balance(self).await
}
async fn balance_info(&self) -> Result<Balance> {
Wallet::balance_info(self).await
}
async fn new_address(&self) -> Result<Address> {
Wallet::new_address(self).await
}
async fn send_to_address(
&self,
address: Address,
amount: Amount,
spending_fee: Amount,
change_override: Option<Address>,
) -> Result<bitcoin::psbt::Psbt> {
Wallet::send_to_address(self, address, amount, spending_fee, change_override).await
}
async fn send_to_address_dynamic_fee(
&self,
address: Address,
amount: Amount,
change_override: Option<Address>,
) -> Result<bitcoin::psbt::Psbt> {
Wallet::send_to_address_dynamic_fee(self, address, amount, change_override).await
}
async fn sweep_balance_to_address_dynamic_fee(
&self,
address: Address,
) -> Result<bitcoin::psbt::Psbt> {
Wallet::sweep_balance_to_address_dynamic_fee(self, address).await
}
async fn sign_and_finalize(&self, psbt: bitcoin::psbt::Psbt) -> Result<bitcoin::Transaction> {
Wallet::sign_and_finalize(self, psbt).await
}
async fn broadcast(
&self,
transaction: bitcoin::Transaction,
kind: &str,
) -> Result<(Txid, bitcoin_wallet::Subscription)> {
Wallet::broadcast(self, transaction, kind).await
}
async fn sync(&self) -> Result<()> {
Wallet::sync(self).await
}
async fn subscribe_to(
&self,
tx: Box<dyn bitcoin_wallet::Watchable>,
) -> bitcoin_wallet::Subscription {
Wallet::subscribe_to(self, tx).await
}
async fn status_of_script(
&self,
tx: &dyn bitcoin_wallet::Watchable,
) -> Result<bitcoin_wallet::primitives::ScriptStatus> {
Wallet::status_of_script(self, tx).await
}
async fn get_raw_transaction(
&self,
txid: Txid,
) -> Result<Option<std::sync::Arc<bitcoin::Transaction>>> {
Wallet::get_raw_transaction(self, txid).await
}
async fn max_giveable(&self, locking_script_size: usize) -> Result<(Amount, Amount)> {
Wallet::max_giveable(self, locking_script_size).await
}
async fn estimate_fee(
&self,
weight: Weight,
transfer_amount: Option<Amount>,
) -> Result<Amount> {
Wallet::estimate_fee(self, weight, transfer_amount).await
}
fn network(&self) -> Network {
self.network
}
fn finality_confirmations(&self) -> u32 {
self.finality_confirmations
}
async fn wallet_export(&self, role: &str) -> Result<FullyNodedExport> {
Wallet::wallet_export(self, role).await
}
}
impl EstimateFeeRate for Client {
async fn estimate_feerate(&self, target_block: u32) -> Result<FeeRate> {
// Now that the Electrum client methods are async, we can parallelize the calls
@ -2342,61 +2405,6 @@ fn trace_status_change(txid: Txid, old: Option<ScriptStatus>, new: ScriptStatus)
new
}
impl Subscription {
pub async fn wait_until_final(&self) -> Result<()> {
let conf_target = self.finality_confirmations;
let txid = self.txid;
tracing::info!(%txid, required_confirmation=%conf_target, "Waiting for Bitcoin transaction finality");
let mut seen_confirmations = 0;
self.wait_until(|status| match status {
ScriptStatus::Confirmed(inner) => {
let confirmations = inner.confirmations();
if confirmations > seen_confirmations {
tracing::info!(%txid,
seen_confirmations = %confirmations,
needed_confirmations = %conf_target,
"Waiting for Bitcoin transaction finality");
seen_confirmations = confirmations;
}
inner.meets_target(conf_target)
}
_ => false,
})
.await
}
pub async fn wait_until_seen(&self) -> Result<()> {
self.wait_until(ScriptStatus::has_been_seen).await
}
pub async fn wait_until_confirmed_with<T>(&self, target: T) -> Result<()>
where
T: Into<u32>,
T: Copy,
{
self.wait_until(|status| status.is_confirmed_with(target))
.await
}
pub async fn wait_until(&self, mut predicate: impl FnMut(&ScriptStatus) -> bool) -> Result<()> {
let mut receiver = self.receiver.clone();
while !predicate(&receiver.borrow()) {
receiver
.changed()
.await
.context("Failed while waiting for next status update")?;
}
Ok(())
}
}
/// Estimate the absolute fee for a transaction.
///
/// This function takes the following parameters:
@ -2617,111 +2625,6 @@ mod mempool_client {
}
}
impl Watchable for (Txid, ScriptBuf) {
fn id(&self) -> Txid {
self.0
}
fn script(&self) -> ScriptBuf {
self.1.clone()
}
}
impl ScriptStatus {
pub fn from_confirmations(confirmations: u32) -> Self {
match confirmations {
0 => Self::InMempool,
confirmations => Self::Confirmed(Confirmed::new(confirmations - 1)),
}
}
}
impl Confirmed {
pub fn new(depth: u32) -> Self {
Self { depth }
}
/// Compute the depth of a transaction based on its inclusion height and the
/// latest known block.
///
/// Our information about the latest block might be outdated. To avoid an
/// overflow, we make sure the depth is 0 in case the inclusion height
/// exceeds our latest known block,
pub fn from_inclusion_and_latest_block(inclusion_height: u32, latest_block: u32) -> Self {
let depth = latest_block.saturating_sub(inclusion_height);
Self { depth }
}
pub fn confirmations(&self) -> u32 {
self.depth + 1
}
pub fn meets_target<T>(&self, target: T) -> bool
where
T: Into<u32>,
{
self.confirmations() >= target.into()
}
pub fn blocks_left_until<T>(&self, target: T) -> u32
where
T: Into<u32> + Copy,
{
if self.meets_target(target) {
0
} else {
target.into() - self.confirmations()
}
}
}
impl ScriptStatus {
/// Check if the script has any confirmations.
pub fn is_confirmed(&self) -> bool {
matches!(self, ScriptStatus::Confirmed(_))
}
/// Check if the script has met the given confirmation target.
pub fn is_confirmed_with<T>(&self, target: T) -> bool
where
T: Into<u32>,
{
match self {
ScriptStatus::Confirmed(inner) => inner.meets_target(target),
_ => false,
}
}
// Calculate the number of blocks left until the target is met.
pub fn blocks_left_until<T>(&self, target: T) -> u32
where
T: Into<u32> + Copy,
{
match self {
ScriptStatus::Confirmed(inner) => inner.blocks_left_until(target),
_ => target.into(),
}
}
pub fn has_been_seen(&self) -> bool {
matches!(self, ScriptStatus::InMempool | ScriptStatus::Confirmed(_))
}
}
impl fmt::Display for ScriptStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ScriptStatus::Unseen => write!(f, "unseen"),
ScriptStatus::InMempool => write!(f, "in mempool"),
ScriptStatus::Retrying => write!(f, "retrying"),
ScriptStatus::Confirmed(inner) => {
write!(f, "confirmed with {} blocks", inner.confirmations())
}
}
}
}
pub mod pre_1_0_0_bdk {
//! This module contains some code for creating a bdk wallet from before the update.
//! We need to keep this around to be able to migrate the wallet.
@ -2989,11 +2892,114 @@ mod tests {
use super::*;
use crate::bitcoin::{PublicKey, TxLock};
use crate::tracing_ext::capture_logs;
use async_trait::async_trait;
use bdk::bitcoin::psbt::Psbt;
use bitcoin::address::NetworkUnchecked;
use bitcoin::hashes::Hash;
use bitcoin_wallet::BitcoinWallet;
use proptest::prelude::*;
use tracing::level_filters::LevelFilter;
// Implement BitcoinWallet trait for a stub wallet and panic when the function is not implemented
#[async_trait]
impl BitcoinWallet for Wallet<Connection, StaticFeeRate> {
async fn balance(&self) -> Result<Amount> {
unimplemented!("stub method called erroniosly")
}
async fn balance_info(&self) -> Result<Balance> {
unimplemented!("stub method called erroniosly")
}
async fn new_address(&self) -> Result<Address> {
unimplemented!("stub method called erroniosly")
}
async fn send_to_address(
&self,
address: Address,
amount: Amount,
spending_fee: Amount,
change_override: Option<Address>,
) -> Result<Psbt> {
unimplemented!("stub method called erroniosly")
}
async fn send_to_address_dynamic_fee(
&self,
address: Address,
amount: Amount,
change_override: Option<Address>,
) -> Result<bitcoin::psbt::Psbt> {
unimplemented!("stub method called erroniosly")
}
async fn sweep_balance_to_address_dynamic_fee(
&self,
address: Address,
) -> Result<bitcoin::psbt::Psbt> {
unimplemented!("stub method called erroniosly")
}
async fn sign_and_finalize(
&self,
psbt: bitcoin::psbt::Psbt,
) -> Result<bitcoin::Transaction> {
unimplemented!("stub method called erroniosly")
}
async fn broadcast(
&self,
transaction: bitcoin::Transaction,
kind: &str,
) -> Result<(Txid, Subscription)> {
unimplemented!("stub method called erroniosly")
}
async fn sync(&self) -> Result<()> {
unimplemented!("stub method called erroniosly")
}
async fn subscribe_to(&self, tx: Box<dyn Watchable>) -> Subscription {
unimplemented!("stub method called erroniosly")
}
async fn status_of_script(&self, tx: &dyn Watchable) -> Result<ScriptStatus> {
unimplemented!("stub method called erroniosly")
}
async fn get_raw_transaction(
&self,
txid: Txid,
) -> Result<Option<std::sync::Arc<bitcoin::Transaction>>> {
unimplemented!("stub method called erroniosly")
}
async fn max_giveable(&self, locking_script_size: usize) -> Result<(Amount, Amount)> {
unimplemented!("stub method called erroniosly")
}
async fn estimate_fee(
&self,
weight: Weight,
transfer_amount: Option<Amount>,
) -> Result<Amount> {
unimplemented!("stub method called erroniosly")
}
fn network(&self) -> Network {
unimplemented!("stub method called erroniosly")
}
fn finality_confirmations(&self) -> u32 {
unimplemented!("stub method called erroniosly")
}
async fn wallet_export(&self, role: &str) -> Result<FullyNodedExport> {
unimplemented!("stub method called erroniosly")
}
}
#[test]
fn given_depth_0_should_meet_confirmation_target_one() {
let script = ScriptStatus::Confirmed(Confirmed { depth: 0 });
@ -3654,17 +3660,3 @@ TRACE swap::bitcoin::wallet: Bitcoin transaction status changed txid=00000000000
}
}
}
#[derive(Clone)]
pub struct SyncRequestBuilderFactory {
chain_tip: bdk_wallet::chain::CheckPoint,
spks: Vec<((KeychainKind, u32), ScriptBuf)>,
}
impl SyncRequestBuilderFactory {
fn build(self) -> SyncRequestBuilder<(KeychainKind, u32)> {
SyncRequest::builder()
.chain_tip(self.chain_tip)
.spks_with_indexes(self.spks)
}
}

View file

@ -1,5 +1,5 @@
use super::tauri_bindings::TauriHandle;
use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock};
use crate::bitcoin::wallet;
use crate::cli::api::tauri_bindings::{
ApprovalRequestType, MoneroNodeConfig, SelectMakerDetails, SendMoneroDetails, TauriEmitter,
TauriSwapProgressEvent,
@ -14,9 +14,9 @@ use crate::monero::MoneroAddressPool;
use crate::network::quote::BidQuote;
use crate::network::rendezvous::XmrBtcNamespace;
use crate::network::swarm;
use crate::protocol::bob::{BobState, Swap};
use crate::protocol::{bob, Database, State};
use crate::{bitcoin, cli, monero};
use crate::protocol::bob::{self, BobState, Swap};
use crate::protocol::{Database, State};
use crate::{cli, monero};
use ::bitcoin::address::NetworkUnchecked;
use ::bitcoin::Txid;
use ::monero::Network;
@ -36,6 +36,8 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use swap_core::bitcoin;
use swap_core::bitcoin::{CancelTimelock, ExpiredTimelocks, PunishTimelock};
use thiserror::Error;
use tokio_util::task::AbortOnDropHandle;
use tor_rtcompat::tokio::TokioRustlsRuntime;

View file

@ -1,11 +1,10 @@
use super::request::BalanceResponse;
use crate::bitcoin;
use crate::cli::api::request::{
GetMoneroBalanceResponse, GetMoneroHistoryResponse, GetMoneroSyncProgressResponse,
};
use crate::cli::list_sellers::QuoteWithAddress;
use crate::monero::MoneroAddressPool;
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
use crate::{monero, network::quote::BidQuote};
use anyhow::{anyhow, bail, Context, Result};
use async_trait::async_trait;
use bitcoin::Txid;
@ -17,6 +16,8 @@ use std::sync::Arc;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use strum::Display;
use swap_core::bitcoin;
use swap_core::bitcoin::ExpiredTimelocks;
use tokio::sync::oneshot;
use typeshare::typeshare;
use uuid::Uuid;

View file

@ -1,4 +1,3 @@
use crate::bitcoin;
use crate::monero::{Scalar, TransferProof};
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
use crate::network::quote::BidQuote;
@ -9,6 +8,7 @@ use crate::network::{
};
use crate::protocol::bob::State2;
use anyhow::{anyhow, Error, Result};
use bitcoin_wallet::BitcoinWallet;
use libp2p::request_response::{
InboundFailure, InboundRequestId, OutboundFailure, OutboundRequestId, ResponseChannel,
};
@ -104,7 +104,7 @@ impl Behaviour {
pub fn new(
alice: PeerId,
env_config: env::Config,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
identify_params: (identity::Keypair, XmrBtcNamespace),
) -> Self {
let agentVersion = format!("cli/{} ({})", env!("CARGO_PKG_VERSION"), identify_params.1);

View file

@ -1,10 +1,11 @@
use crate::bitcoin::{ExpiredTimelocks, Wallet};
use crate::bitcoin::Wallet;
use crate::monero::BlockHeight;
use crate::protocol::bob::BobState;
use crate::protocol::Database;
use anyhow::{bail, Result};
use bitcoin::Txid;
use std::sync::Arc;
use swap_core::bitcoin::ExpiredTimelocks;
use uuid::Uuid;
pub async fn cancel_and_refund(

View file

@ -1,4 +1,3 @@
use crate::bitcoin::{bitcoin_address, Amount};
use crate::cli::api::request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ExportBitcoinWalletArgs, GetConfigArgs,
GetHistoryArgs, ListSellersArgs, MoneroRecoveryArgs, Request, ResumeSwapArgs, WithdrawBtcArgs,
@ -12,6 +11,7 @@ use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use structopt::{clap, StructOpt};
use swap_core::bitcoin::{bitcoin_address, Amount};
use url::Url;
use uuid::Uuid;

View file

@ -1,4 +1,3 @@
use crate::bitcoin::EncryptedSignature;
use crate::cli::behaviour::{Behaviour, OutEvent};
use crate::monero;
use crate::network::cooperative_xmr_redeem_after_punish::{self, Request, Response};
@ -18,6 +17,7 @@ use libp2p::{PeerId, Swarm};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use swap_core::bitcoin::EncryptedSignature;
use uuid::Uuid;
static REQUEST_RESPONSE_PROTOCOL_TIMEOUT: Duration = Duration::from_secs(60);

View file

@ -1,7 +1,7 @@
use super::api::tauri_bindings::{BackgroundRefundProgress, TauriBackgroundProgress, TauriEmitter};
use super::api::SwapLock;
use super::cancel_and_refund;
use crate::bitcoin::{ExpiredTimelocks, Wallet};
use crate::bitcoin::Wallet;
use crate::cli::api::tauri_bindings::TauriHandle;
use crate::protocol::bob::BobState;
use crate::protocol::{Database, State};
@ -9,6 +9,7 @@ use anyhow::{Context, Result};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use swap_core::bitcoin::ExpiredTimelocks;
use uuid::Uuid;
/// A long running task which watches for changes to timelocks and balance

View file

@ -1,4 +1,3 @@
use crate::bitcoin::EncryptedSignature;
use crate::monero;
use crate::monero::BlockHeight;
use crate::monero::TransferProof;
@ -6,6 +5,7 @@ use crate::protocol::alice;
use crate::protocol::alice::AliceState;
use serde::{Deserialize, Serialize};
use std::fmt;
use swap_core::bitcoin::EncryptedSignature;
// Large enum variant is fine because this is only used for database
// and is dropped once written in DB.

View file

@ -23,11 +23,7 @@ pub mod common;
pub mod database;
pub mod libp2p_ext;
pub mod monero;
mod monero_ext;
pub mod network;
pub mod protocol;
pub mod seed;
pub mod tracing_ext;
#[cfg(test)]
mod proptest;

View file

@ -4,842 +4,5 @@ pub mod wallet_rpc;
pub use ::monero::network::Network;
pub use ::monero::{Address, PrivateKey, PublicKey};
pub use curve25519_dalek::scalar::Scalar;
pub use wallet::{Daemon, Wallet, Wallets, WatchRequest};
use crate::bitcoin;
use anyhow::{bail, Result};
use rand::{CryptoRng, RngCore};
use rust_decimal::prelude::*;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use std::fmt;
use std::ops::{Add, Mul, Sub};
use std::str::FromStr;
use typeshare::typeshare;
pub const PICONERO_OFFSET: u64 = 1_000_000_000_000;
/// A Monero block height.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct BlockHeight {
pub height: u64,
}
impl fmt::Display for BlockHeight {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.height)
}
}
pub fn private_key_from_secp256k1_scalar(scalar: bitcoin::Scalar) -> PrivateKey {
let mut bytes = scalar.to_bytes();
// we must reverse the bytes because a secp256k1 scalar is big endian, whereas a
// ed25519 scalar is little endian
bytes.reverse();
PrivateKey::from_scalar(Scalar::from_bytes_mod_order(bytes))
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrivateViewKey(#[serde(with = "swap_serde::monero::private_key")] PrivateKey);
impl fmt::Display for PrivateViewKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Delegate to the Display implementation of PrivateKey
write!(f, "{}", self.0)
}
}
impl PrivateViewKey {
pub fn new_random<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
let scalar = Scalar::random(rng);
let private_key = PrivateKey::from_scalar(scalar);
Self(private_key)
}
pub fn public(&self) -> PublicViewKey {
PublicViewKey(PublicKey::from_private_key(&self.0))
}
}
impl Add for PrivateViewKey {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl From<PrivateViewKey> for PrivateKey {
fn from(from: PrivateViewKey) -> Self {
from.0
}
}
impl From<PublicViewKey> for PublicKey {
fn from(from: PublicViewKey) -> Self {
from.0
}
}
#[derive(Clone, Copy, Debug)]
pub struct PublicViewKey(PublicKey);
/// Our own monero amount type, which we need because the monero crate
/// doesn't implement Serialize and Deserialize.
#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd)]
#[typeshare(serialized_as = "number")]
pub struct Amount(u64);
// TX Fees on Monero can be found here:
// - https://www.monero.how/monero-transaction-fees
// - https://bitinfocharts.com/comparison/monero-transactionfees.html#1y
//
// In the last year the highest avg fee on any given day was around 0.00075 XMR
// We use a multiplier of 4x to stay safe
// 0.00075 XMR * 4 = 0.003 XMR (around $1 as of Jun. 4th 2025)
// We DO NOT use this fee to construct any transactions. It is only to **estimate** how much
// we need to reserve for the fee when determining our max giveable amount
// We use a VERY conservative value here to stay on the safe side. We want to avoid not being able
// to lock as much as we previously estimated.
pub const CONSERVATIVE_MONERO_FEE: Amount = Amount::from_piconero(3_000_000_000);
impl Amount {
pub const ZERO: Self = Self(0);
pub const ONE_XMR: Self = Self(PICONERO_OFFSET);
/// Create an [Amount] with piconero precision and the given number of
/// piconeros.
///
/// A piconero (a.k.a atomic unit) is equal to 1e-12 XMR.
pub const fn from_piconero(amount: u64) -> Self {
Amount(amount)
}
/// Return Monero Amount as Piconero.
pub fn as_piconero(&self) -> u64 {
self.0
}
/// Return Monero Amount as XMR.
pub fn as_xmr(&self) -> f64 {
let amount_decimal = Decimal::from(self.0);
let offset_decimal = Decimal::from(PICONERO_OFFSET);
let result = amount_decimal / offset_decimal;
// Convert to f64 only at the end, after the division
result
.to_f64()
.expect("Conversion from piconero to XMR should not overflow f64")
}
/// Calculate the conservative max giveable of Monero we can spent given [`self`] is the balance
/// of a Monero wallet
/// This is going to be LESS than we can really spent because we assume a high fee
pub fn max_conservative_giveable(&self) -> Self {
let pico_minus_fee = self
.as_piconero()
.saturating_sub(CONSERVATIVE_MONERO_FEE.as_piconero());
Self::from_piconero(pico_minus_fee)
}
/// Calculate the Monero balance needed to send the [`self`] Amount to another address
/// E.g: Amount(1 XMR).min_conservative_balance_to_spend() with a fee of 0.1 XMR would be 1.1 XMR
/// This is going to be MORE than we really need because we assume a high fee
pub fn min_conservative_balance_to_spend(&self) -> Self {
let pico_minus_fee = self
.as_piconero()
.saturating_add(CONSERVATIVE_MONERO_FEE.as_piconero());
Self::from_piconero(pico_minus_fee)
}
/// Calculate the maximum amount of Bitcoin that can be bought at a given
/// asking price for this amount of Monero including the median fee.
pub fn max_bitcoin_for_price(&self, ask_price: bitcoin::Amount) -> Option<bitcoin::Amount> {
let pico_minus_fee = self.max_conservative_giveable();
if pico_minus_fee.as_piconero() == 0 {
return Some(bitcoin::Amount::ZERO);
}
// safely convert the BTC/XMR rate to sat/pico
let ask_sats = Decimal::from(ask_price.to_sat());
let pico_per_xmr = Decimal::from(PICONERO_OFFSET);
let ask_sats_per_pico = ask_sats / pico_per_xmr;
let pico = Decimal::from(pico_minus_fee.as_piconero());
let max_sats = pico.checked_mul(ask_sats_per_pico)?;
let satoshi = max_sats.to_u64()?;
Some(bitcoin::Amount::from_sat(satoshi))
}
pub fn from_monero(amount: f64) -> Result<Self> {
let decimal = Decimal::try_from(amount)?;
Self::from_decimal(decimal)
}
pub fn parse_monero(amount: &str) -> Result<Self> {
let decimal = Decimal::from_str(amount)?;
Self::from_decimal(decimal)
}
pub fn as_piconero_decimal(&self) -> Decimal {
Decimal::from(self.as_piconero())
}
fn from_decimal(amount: Decimal) -> Result<Self> {
let piconeros_dec =
amount.mul(Decimal::from_u64(PICONERO_OFFSET).expect("constant to fit into u64"));
let piconeros = piconeros_dec
.to_u64()
.ok_or_else(|| OverflowError(amount.to_string()))?;
Ok(Amount(piconeros))
}
/// Subtract but throw an error on underflow.
pub fn checked_sub(self, rhs: Amount) -> Result<Self> {
if self.0 < rhs.0 {
bail!("checked sub would underflow");
}
Ok(Amount::from_piconero(self.0 - rhs.0))
}
}
/// A Monero address with an associated percentage and human-readable label.
///
/// This structure represents a destination address for Monero transactions
/// along with the percentage of funds it should receive and a descriptive label.
#[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: Option<monero::Address>,
#[typeshare(serialized_as = "number")]
percentage: Decimal,
label: String,
}
impl LabeledMoneroAddress {
/// Creates a new labeled Monero address.
///
/// # Arguments
///
/// * `address` - The Monero address
/// * `percentage` - The percentage of funds (between 0.0 and 1.0)
/// * `label` - A human-readable label for this address
///
/// # Errors
///
/// Returns an error if the percentage is not between 0.0 and 1.0 inclusive.
fn new(
address: impl Into<Option<monero::Address>>,
percentage: Decimal,
label: String,
) -> Result<Self> {
if percentage < Decimal::ZERO || percentage > Decimal::ONE {
bail!(
"Percentage must be between 0 and 1 inclusive, got: {}",
percentage
);
}
Ok(Self {
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) -> Option<monero::Address> {
self.address.clone()
}
/// Returns the percentage as a decimal.
pub fn percentage(&self) -> Decimal {
self.percentage
}
/// Returns the human-readable label.
pub fn label(&self) -> &str {
&self.label
}
}
/// A collection of labeled Monero addresses that can receive funds in a transaction.
///
/// This structure manages multiple destination addresses with their associated
/// percentages and labels. It's used for splitting Monero transactions across
/// multiple recipients, such as for donations or multi-destination swaps.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[typeshare]
pub struct MoneroAddressPool(Vec<LabeledMoneroAddress>);
use rust_decimal::prelude::ToPrimitive;
impl MoneroAddressPool {
/// Creates a new address pool from a vector of labeled addresses.
///
/// # Arguments
///
/// * `addresses` - Vector of labeled Monero addresses
pub fn new(addresses: Vec<LabeledMoneroAddress>) -> Self {
Self(addresses)
}
/// Returns a vector of all Monero addresses in the pool.
pub fn addresses(&self) -> Vec<Option<monero::Address>> {
self.0.iter().map(|address| address.address()).collect()
}
/// Returns a vector of all percentages as f64 values (0-1 range).
pub fn percentages(&self) -> Vec<f64> {
self.0
.iter()
.map(|address| {
address
.percentage()
.to_f64()
.expect("Decimal should convert to f64")
})
.collect()
}
/// Returns an iterator over the labeled addresses.
pub fn iter(&self) -> impl Iterator<Item = &LabeledMoneroAddress> {
self.0.iter()
}
/// Validates that all addresses in the pool are on the expected network.
///
/// # Arguments
///
/// * `network` - The expected Monero network
///
/// # Errors
///
/// 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 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);
}
}
}
Ok(())
}
/// Assert that the sum of the percentages in the address pool is 1 (allowing for a small tolerance)
pub fn assert_sum_to_one(&self) -> Result<()> {
let sum = self
.0
.iter()
.map(|address| address.percentage())
.sum::<Decimal>();
const TOLERANCE: f64 = 1e-6;
if (sum - Decimal::ONE).abs()
> Decimal::from_f64(TOLERANCE).expect("TOLERANCE constant should be a valid f64")
{
bail!("Address pool percentages do not sum to 1");
}
Ok(())
}
/// Returns a vector of addresses with the empty addresses filled with the given primary address
pub fn fill_empty_addresses(&self, primary_address: monero::Address) -> Vec<monero::Address> {
self.0
.iter()
.map(|address| address.address().unwrap_or(primary_address))
.collect()
}
}
impl From<::monero::Address> for MoneroAddressPool {
fn from(address: ::monero::Address) -> Self {
Self(vec![LabeledMoneroAddress::new(
address,
Decimal::from(1),
"user address".to_string(),
)
.expect("Percentage 1 is always valid")])
}
}
impl Add for Amount {
type Output = Amount;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl Sub<Amount> for Amount {
type Output = Amount;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
impl Mul<u64> for Amount {
type Output = Amount;
fn mul(self, rhs: u64) -> Self::Output {
Self(self.0 * rhs)
}
}
impl From<Amount> for u64 {
fn from(from: Amount) -> u64 {
from.0
}
}
impl From<::monero::Amount> for Amount {
fn from(from: ::monero::Amount) -> Self {
Amount::from_piconero(from.as_pico())
}
}
impl From<Amount> for ::monero::Amount {
fn from(from: Amount) -> Self {
::monero::Amount::from_pico(from.as_piconero())
}
}
impl fmt::Display for Amount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut decimal = Decimal::from(self.0);
decimal
.set_scale(12)
.expect("12 is smaller than max precision of 28");
write!(f, "{} XMR", decimal)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TransferProof {
tx_hash: TxHash,
#[serde(with = "swap_serde::monero::private_key")]
tx_key: PrivateKey,
}
impl TransferProof {
pub fn new(tx_hash: TxHash, tx_key: PrivateKey) -> Self {
Self { tx_hash, tx_key }
}
pub fn tx_hash(&self) -> TxHash {
self.tx_hash.clone()
}
pub fn tx_key(&self) -> PrivateKey {
self.tx_key
}
}
// TODO: add constructor/ change String to fixed length byte array
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TxHash(pub String);
impl From<TxHash> for String {
fn from(from: TxHash) -> Self {
from.0
}
}
impl fmt::Debug for TxHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Display for TxHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[error("expected {expected}, got {actual}")]
pub struct InsufficientFunds {
pub expected: Amount,
pub actual: Amount,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[error("Overflow, cannot convert {0} to u64")]
pub struct OverflowError(pub String);
pub mod monero_amount {
use crate::monero::Amount;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(x: &Amount, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.serialize_u64(x.as_piconero())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Amount, <D as Deserializer<'de>>::Error>
where
D: Deserializer<'de>,
{
let picos = u64::deserialize(deserializer)?;
let amount = Amount::from_piconero(picos);
Ok(amount)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_monero_min() {
let min_pics = 1;
let amount = Amount::from_piconero(min_pics);
let monero = amount.to_string();
assert_eq!("0.000000000001 XMR", monero);
}
#[test]
fn display_monero_one() {
let min_pics = 1000000000000;
let amount = Amount::from_piconero(min_pics);
let monero = amount.to_string();
assert_eq!("1.000000000000 XMR", monero);
}
#[test]
fn display_monero_max() {
let max_pics = 18_446_744_073_709_551_615;
let amount = Amount::from_piconero(max_pics);
let monero = amount.to_string();
assert_eq!("18446744.073709551615 XMR", monero);
}
#[test]
fn parse_monero_min() {
let monero_min = "0.000000000001";
let amount = Amount::parse_monero(monero_min).unwrap();
let pics = amount.0;
assert_eq!(1, pics);
}
#[test]
fn parse_monero() {
let monero = "123";
let amount = Amount::parse_monero(monero).unwrap();
let pics = amount.0;
assert_eq!(123000000000000, pics);
}
#[test]
fn parse_monero_max() {
let monero = "18446744.073709551615";
let amount = Amount::parse_monero(monero).unwrap();
let pics = amount.0;
assert_eq!(18446744073709551615, pics);
}
#[test]
fn parse_monero_overflows() {
let overflow_pics = "18446744.073709551616";
let error = Amount::parse_monero(overflow_pics).unwrap_err();
assert_eq!(
error.downcast_ref::<OverflowError>().unwrap(),
&OverflowError(overflow_pics.to_owned())
);
}
#[test]
fn max_bitcoin_to_trade() {
// sanity check: if the asking price is 1 BTC / 1 XMR
// and we have μ XMR + fee
// then max BTC we can buy is μ
let ask = bitcoin::Amount::from_btc(1.0).unwrap();
let xmr = Amount::parse_monero("1.0").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(1.0).unwrap());
let xmr = Amount::parse_monero("0.5").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(0.5).unwrap());
let xmr = Amount::parse_monero("2.5").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(2.5).unwrap());
let xmr = Amount::parse_monero("420").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(420.0).unwrap());
let xmr = Amount::parse_monero("0.00001").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(0.00001).unwrap());
// other ask prices
let ask = bitcoin::Amount::from_btc(0.5).unwrap();
let xmr = Amount::parse_monero("2").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(1.0).unwrap());
let ask = bitcoin::Amount::from_btc(2.0).unwrap();
let xmr = Amount::parse_monero("1").unwrap() + CONSERVATIVE_MONERO_FEE;
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_btc(2.0).unwrap());
let ask = bitcoin::Amount::from_sat(382_900);
let xmr = Amount::parse_monero("10").unwrap();
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_sat(3_827_851));
// example from https://github.com/comit-network/xmr-btc-swap/issues/1084
// with rate from kraken at that time
let ask = bitcoin::Amount::from_sat(685_800);
let xmr = Amount::parse_monero("0.826286435921").unwrap();
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(btc, bitcoin::Amount::from_sat(564_609));
}
#[test]
fn max_bitcoin_to_trade_overflow() {
let xmr = Amount::from_monero(30.0).unwrap();
let ask = bitcoin::Amount::from_sat(728_688);
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(bitcoin::Amount::from_sat(21_858_453), btc);
let xmr = Amount::from_piconero(u64::MAX);
let ask = bitcoin::Amount::from_sat(u64::MAX);
let btc = xmr.max_bitcoin_for_price(ask);
assert!(btc.is_none());
}
#[test]
fn geting_max_bitcoin_to_trade_with_balance_smaller_than_locking_fee() {
let ask = bitcoin::Amount::from_sat(382_900);
let xmr = Amount::parse_monero("0.00001").unwrap();
let btc = xmr.max_bitcoin_for_price(ask).unwrap();
assert_eq!(bitcoin::Amount::ZERO, btc);
}
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MoneroPrivateKey(#[serde(with = "monero_private_key")] crate::monero::PrivateKey);
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MoneroAmount(#[serde(with = "monero_amount")] crate::monero::Amount);
#[test]
fn serde_monero_private_key_json() {
let key = MoneroPrivateKey(monero::PrivateKey::from_scalar(
crate::monero::Scalar::random(&mut OsRng),
));
let encoded = serde_json::to_vec(&key).unwrap();
let decoded: MoneroPrivateKey = serde_json::from_slice(&encoded).unwrap();
assert_eq!(key, decoded);
}
#[test]
fn serde_monero_private_key_cbor() {
let key = MoneroPrivateKey(monero::PrivateKey::from_scalar(
crate::monero::Scalar::random(&mut OsRng),
));
let encoded = serde_cbor::to_vec(&key).unwrap();
let decoded: MoneroPrivateKey = serde_cbor::from_slice(&encoded).unwrap();
assert_eq!(key, decoded);
}
#[test]
fn serde_monero_amount() {
let amount = MoneroAmount(crate::monero::Amount::from_piconero(1000));
let encoded = serde_cbor::to_vec(&amount).unwrap();
let decoded: MoneroAmount = serde_cbor::from_slice(&encoded).unwrap();
assert_eq!(amount, decoded);
}
#[test]
fn max_conservative_giveable_basic() {
// Test with balance larger than fee
let balance = Amount::parse_monero("1.0").unwrap();
let giveable = balance.max_conservative_giveable();
let expected = balance.as_piconero() - CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(giveable.as_piconero(), expected);
}
#[test]
fn max_conservative_giveable_exact_fee() {
// Test with balance exactly equal to fee
let balance = CONSERVATIVE_MONERO_FEE;
let giveable = balance.max_conservative_giveable();
assert_eq!(giveable, Amount::ZERO);
}
#[test]
fn max_conservative_giveable_less_than_fee() {
// Test with balance less than fee (should saturate to 0)
let balance = Amount::from_piconero(CONSERVATIVE_MONERO_FEE.as_piconero() / 2);
let giveable = balance.max_conservative_giveable();
assert_eq!(giveable, Amount::ZERO);
}
#[test]
fn max_conservative_giveable_zero_balance() {
// Test with zero balance
let balance = Amount::ZERO;
let giveable = balance.max_conservative_giveable();
assert_eq!(giveable, Amount::ZERO);
}
#[test]
fn max_conservative_giveable_large_balance() {
// Test with large balance
let balance = Amount::parse_monero("100.0").unwrap();
let giveable = balance.max_conservative_giveable();
let expected = balance.as_piconero() - CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(giveable.as_piconero(), expected);
// Ensure the result makes sense
assert!(giveable.as_piconero() > 0);
assert!(giveable < balance);
}
#[test]
fn min_conservative_balance_to_spend_basic() {
// Test with 1 XMR amount to send
let amount_to_send = Amount::parse_monero("1.0").unwrap();
let min_balance = amount_to_send.min_conservative_balance_to_spend();
let expected = amount_to_send.as_piconero() + CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(min_balance.as_piconero(), expected);
}
#[test]
fn min_conservative_balance_to_spend_zero() {
// Test with zero amount to send
let amount_to_send = Amount::ZERO;
let min_balance = amount_to_send.min_conservative_balance_to_spend();
assert_eq!(min_balance, CONSERVATIVE_MONERO_FEE);
}
#[test]
fn min_conservative_balance_to_spend_small_amount() {
// Test with small amount
let amount_to_send = Amount::from_piconero(1000);
let min_balance = amount_to_send.min_conservative_balance_to_spend();
let expected = 1000 + CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(min_balance.as_piconero(), expected);
}
#[test]
fn min_conservative_balance_to_spend_large_amount() {
// Test with large amount
let amount_to_send = Amount::parse_monero("50.0").unwrap();
let min_balance = amount_to_send.min_conservative_balance_to_spend();
let expected = amount_to_send.as_piconero() + CONSERVATIVE_MONERO_FEE.as_piconero();
assert_eq!(min_balance.as_piconero(), expected);
// Ensure the result makes sense
assert!(min_balance > amount_to_send);
assert!(min_balance > CONSERVATIVE_MONERO_FEE);
}
#[test]
fn conservative_fee_functions_are_inverse() {
// Test that the functions are somewhat inverse of each other
let original_balance = Amount::parse_monero("5.0").unwrap();
// Get max giveable amount
let max_giveable = original_balance.max_conservative_giveable();
// Calculate min balance needed to send that amount
let min_balance_needed = max_giveable.min_conservative_balance_to_spend();
// The min balance needed should be equal to or slightly more than the original balance
// (due to the conservative nature of the fee estimation)
assert!(min_balance_needed >= original_balance);
// The difference should be at most the conservative fee
let difference = min_balance_needed.as_piconero() - original_balance.as_piconero();
assert!(difference <= CONSERVATIVE_MONERO_FEE.as_piconero());
}
#[test]
fn conservative_fee_edge_cases() {
// Test with maximum possible amount
let max_amount = Amount::from_piconero(u64::MAX - CONSERVATIVE_MONERO_FEE.as_piconero());
let giveable = max_amount.max_conservative_giveable();
assert!(giveable.as_piconero() > 0);
// Test min balance calculation doesn't overflow
let large_amount = Amount::from_piconero(u64::MAX / 2);
let min_balance = large_amount.min_conservative_balance_to_spend();
assert!(min_balance > large_amount);
}
#[test]
fn labeled_monero_address_percentage_validation() {
use rust_decimal::Decimal;
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());
assert!(LabeledMoneroAddress::new(address, Decimal::ONE, "test".to_string()).is_ok());
assert!(LabeledMoneroAddress::new(address, Decimal::new(5, 1), "test".to_string()).is_ok()); // 0.5
assert!(
LabeledMoneroAddress::new(address, Decimal::new(9925, 4), "test".to_string()).is_ok()
); // 0.9925
// Invalid percentages should fail
assert!(
LabeledMoneroAddress::new(address, Decimal::new(-1, 0), "test".to_string()).is_err()
);
assert!(
LabeledMoneroAddress::new(address, Decimal::new(11, 1), "test".to_string()).is_err()
); // 1.1
assert!(
LabeledMoneroAddress::new(address, Decimal::new(2, 0), "test".to_string()).is_err()
); // 2.0
}
}
pub use swap_core::monero::primitives::*;
pub use wallet::{Daemon, Wallet, Wallets};

View file

@ -5,14 +5,14 @@
//! - wait for transactions to be confirmed
//! - send money from one wallet to another.
use std::{path::PathBuf, sync::Arc, time::Duration};
pub use monero_sys::{Daemon, WalletHandle as Wallet, WalletHandleListener};
use crate::common::throttle::{throttle, Throttle};
use anyhow::{Context, Result};
use monero::{Address, Network};
use monero_simple_request_rpc::SimpleRequestRpc;
use monero_sys::WalletEventListener;
pub use monero_sys::{Daemon, WalletHandle as Wallet, WalletHandleListener};
use std::{path::PathBuf, sync::Arc, time::Duration};
use tokio::sync::RwLock;
use uuid::Uuid;
@ -21,7 +21,7 @@ use crate::cli::api::{
tauri_bindings::{MoneroWalletUpdate, TauriEmitter, TauriEvent, TauriHandle},
};
use super::{BlockHeight, TransferProof, TxHash};
use super::{BlockHeight, TxHash, WatchRequest};
/// Entrance point to the Monero blockchain.
/// You can use this struct to open specific wallets and monitor the blockchain.
@ -45,25 +45,6 @@ pub struct Wallets {
wallet_database: Option<Arc<monero_sys::Database>>,
}
/// A request to watch for a transfer.
pub struct WatchRequest {
pub public_view_key: super::PublicViewKey,
pub public_spend_key: monero::PublicKey,
/// The proof of the transfer.
pub transfer_proof: TransferProof,
/// The expected amount of the transfer.
pub expected_amount: monero::Amount,
/// The number of confirmations required for the transfer to be considered confirmed.
pub confirmation_target: u64,
}
/// Transfer a specified amount of money to a specified address.
pub struct TransferRequest {
pub public_spend_key: monero::PublicKey,
pub public_view_key: super::PublicViewKey,
pub amount: monero::Amount,
}
struct TauriWalletListener {
// one throttle wrapper per expensive update
balance_throttle: Throttle<()>,
@ -513,15 +494,6 @@ impl Wallets {
}
}
impl TransferRequest {
pub fn address_and_amount(&self, network: Network) -> (Address, monero::Amount) {
(
Address::standard(network, self.public_spend_key, self.public_view_key.0),
self.amount,
)
}
}
/// Pass this to [`Wallet::wait_until_confirmed`] or [`Wallet::wait_until_synced`]
/// to not receive any confirmation callbacks.
pub fn no_listener<T>() -> Option<impl Fn(T) + Send + 'static> {

View file

@ -1,29 +1,8 @@
use ::monero::Network;
use anyhow::{bail, Context, Error, Result};
use once_cell::sync::Lazy;
use anyhow::{Context, Error, Result};
use serde::Deserialize;
use std::fmt;
use std::fmt::{Display, Formatter};
use std::time::Duration;
// See: https://www.moneroworld.com/#nodes, https://monero.fail
// We don't need any testnet nodes because we don't support testnet at all
static MONERO_DAEMONS: Lazy<[MoneroDaemon; 12]> = Lazy::new(|| {
[
MoneroDaemon::new("http://xmr-node.cakewallet.com:18081", Network::Mainnet),
MoneroDaemon::new("http://nodex.monerujo.io:18081", Network::Mainnet),
MoneroDaemon::new("http://nodes.hashvault.pro:18081", Network::Mainnet),
MoneroDaemon::new("http://p2pmd.xmrvsbeast.com:18081", Network::Mainnet),
MoneroDaemon::new("http://node.monerodevs.org:18089", Network::Mainnet),
MoneroDaemon::new("http://xmr-node-uk.cakewallet.com:18081", Network::Mainnet),
MoneroDaemon::new("http://xmr.litepay.ch:18081", Network::Mainnet),
MoneroDaemon::new("http://stagenet.xmr-tw.org:38081", Network::Stagenet),
MoneroDaemon::new("http://node.monerodevs.org:38089", Network::Stagenet),
MoneroDaemon::new("http://singapore.node.xmr.pm:38081", Network::Stagenet),
MoneroDaemon::new("http://xmr-lux.boldsuck.org:38081", Network::Stagenet),
MoneroDaemon::new("http://stagenet.community.rino.io:38081", Network::Stagenet),
]
});
#[derive(Debug, Clone)]
pub struct MoneroDaemon {
@ -92,40 +71,6 @@ struct MoneroDaemonGetInfoResponse {
testnet: bool,
}
/// Chooses an available Monero daemon based on the specified network.
async fn choose_monero_daemon(network: Network) -> Result<MoneroDaemon, Error> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.https_only(false)
.build()?;
// We only want to check for daemons that match the specified network
let network_matching_daemons = MONERO_DAEMONS
.iter()
.filter(|daemon| daemon.network == network);
for daemon in network_matching_daemons {
match daemon.is_available(&client).await {
Ok(true) => {
tracing::debug!(%daemon, "Found available Monero daemon");
return Ok(daemon.clone());
}
Err(err) => {
tracing::debug!(?err, %daemon, "Failed to connect to Monero daemon");
continue;
}
Ok(false) => continue,
}
}
bail!("No Monero daemon could be found. Please specify one manually or try again later.")
}
/// Public wrapper around [`choose_monero_daemon`].
pub async fn choose_monero_node(network: Network) -> Result<MoneroDaemon, Error> {
choose_monero_daemon(network).await
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -23,7 +23,7 @@ impl AsRef<str> for EncryptedSignatureProtocol {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub swap_id: Uuid,
pub tx_redeem_encsig: crate::bitcoin::EncryptedSignature,
pub tx_redeem_encsig: swap_core::bitcoin::EncryptedSignature,
}
pub fn alice() -> Behaviour {

View file

@ -1,9 +1,10 @@
use std::time::Duration;
use crate::{asb, bitcoin, cli};
use crate::{asb, cli};
use libp2p::request_response::{self, ProtocolSupport};
use libp2p::{PeerId, StreamProtocol};
use serde::{Deserialize, Serialize};
use swap_core::bitcoin;
use typeshare::typeshare;
const PROTOCOL: &str = "/comit/xmr/btc/bid-quote/1.0.0";

View file

@ -5,8 +5,9 @@ use crate::network::swap_setup::{
};
use crate::protocol::alice::{State0, State3};
use crate::protocol::{Message0, Message2, Message4};
use crate::{asb, bitcoin, monero};
use crate::{asb, monero};
use anyhow::{anyhow, Context, Result};
use bitcoin_wallet::BitcoinWallet;
use futures::future::{BoxFuture, OptionFuture};
use futures::AsyncWriteExt;
use futures::FutureExt;
@ -17,8 +18,10 @@ use libp2p::swarm::{ConnectionHandlerEvent, NetworkBehaviour, SubstreamProtocol,
use libp2p::{Multiaddr, PeerId};
use std::collections::VecDeque;
use std::fmt::Debug;
use std::sync::Arc;
use std::task::Poll;
use std::time::{Duration, Instant};
use swap_core::bitcoin;
use swap_env::env;
use uuid::Uuid;
@ -55,7 +58,7 @@ pub struct WalletSnapshot {
impl WalletSnapshot {
pub async fn capture(
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: &monero::Wallets,
external_redeem_address: &Option<bitcoin::Address>,
transfer_amount: bitcoin::Amount,

View file

@ -1,8 +1,9 @@
use crate::network::swap_setup::{protocol, BlockchainNetwork, SpotPriceError, SpotPriceResponse};
use crate::protocol::bob::{State0, State2};
use crate::protocol::{Message1, Message3};
use crate::{bitcoin, cli, monero};
use crate::{cli, monero};
use anyhow::{Context, Result};
use bitcoin_wallet::BitcoinWallet;
use futures::future::{BoxFuture, OptionFuture};
use futures::AsyncWriteExt;
use futures::FutureExt;
@ -16,6 +17,7 @@ use std::collections::VecDeque;
use std::sync::Arc;
use std::task::Poll;
use std::time::Duration;
use swap_core::bitcoin;
use swap_env::env;
use uuid::Uuid;
@ -24,13 +26,13 @@ use super::{read_cbor_message, write_cbor_message, SpotPriceRequest};
#[allow(missing_debug_implementations)]
pub struct Behaviour {
env_config: env::Config,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
new_swaps: VecDeque<(PeerId, NewSwap)>,
completed_swaps: VecDeque<(PeerId, Completed)>,
}
impl Behaviour {
pub fn new(env_config: env::Config, bitcoin_wallet: Arc<bitcoin::Wallet>) -> Self {
pub fn new(env_config: env::Config, bitcoin_wallet: Arc<dyn BitcoinWallet>) -> Self {
Self {
env_config,
bitcoin_wallet,
@ -116,12 +118,12 @@ pub struct Handler {
env_config: env::Config,
timeout: Duration,
new_swaps: VecDeque<NewSwap>,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
keep_alive: bool,
}
impl Handler {
fn new(env_config: env::Config, bitcoin_wallet: Arc<bitcoin::Wallet>) -> Self {
fn new(env_config: env::Config, bitcoin_wallet: Arc<dyn BitcoinWallet>) -> Self {
Self {
env_config,
outbound_stream: OptionFuture::from(None),

View file

@ -2,7 +2,7 @@ use crate::asb::{LatestRate, RendezvousNode};
use crate::libp2p_ext::MultiAddrExt;
use crate::network::rendezvous::XmrBtcNamespace;
use crate::seed::Seed;
use crate::{asb, bitcoin, cli};
use crate::{asb, cli};
use anyhow::Result;
use arti_client::TorClient;
use libp2p::swarm::NetworkBehaviour;
@ -11,6 +11,7 @@ use libp2p::{identity, Multiaddr, Swarm};
use std::fmt::Debug;
use std::sync::Arc;
use std::time::Duration;
use swap_core::bitcoin;
use swap_env::env;
use tor_rtcompat::tokio::TokioRustlsRuntime;

View file

@ -1,167 +1,4 @@
use crate::monero::MoneroAddressPool;
use crate::protocol::alice::swap::is_complete as alice_is_complete;
use crate::protocol::alice::AliceState;
use crate::protocol::bob::swap::is_complete as bob_is_complete;
use crate::protocol::bob::BobState;
use crate::{bitcoin, monero};
use anyhow::Result;
use async_trait::async_trait;
use conquer_once::Lazy;
use libp2p::{Multiaddr, PeerId};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use sigma_fun::ext::dl_secp256k1_ed25519_eq::{CrossCurveDLEQ, CrossCurveDLEQProof};
use sigma_fun::HashTranscript;
use std::convert::TryInto;
use uuid::Uuid;
pub mod alice;
pub mod bob;
pub static CROSS_CURVE_PROOF_SYSTEM: Lazy<
CrossCurveDLEQ<HashTranscript<Sha256, rand_chacha::ChaCha20Rng>>,
> = Lazy::new(|| {
CrossCurveDLEQ::<HashTranscript<Sha256, rand_chacha::ChaCha20Rng>>::new(
(*ecdsa_fun::fun::G).normalize(),
curve25519_dalek::constants::ED25519_BASEPOINT_POINT,
)
});
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message0 {
swap_id: Uuid,
B: bitcoin::PublicKey,
S_b_monero: monero::PublicKey,
S_b_bitcoin: bitcoin::PublicKey,
dleq_proof_s_b: CrossCurveDLEQProof,
v_b: monero::PrivateViewKey,
#[serde(with = "swap_serde::bitcoin::address_serde")]
refund_address: bitcoin::Address,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
tx_refund_fee: bitcoin::Amount,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
tx_cancel_fee: bitcoin::Amount,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message1 {
A: bitcoin::PublicKey,
S_a_monero: monero::PublicKey,
S_a_bitcoin: bitcoin::PublicKey,
dleq_proof_s_a: CrossCurveDLEQProof,
v_a: monero::PrivateViewKey,
#[serde(with = "swap_serde::bitcoin::address_serde")]
redeem_address: bitcoin::Address,
#[serde(with = "swap_serde::bitcoin::address_serde")]
punish_address: bitcoin::Address,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
tx_redeem_fee: bitcoin::Amount,
#[serde(with = "::bitcoin::amount::serde::as_sat")]
tx_punish_fee: bitcoin::Amount,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message2 {
psbt: bitcoin::PartiallySignedTransaction,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message3 {
tx_cancel_sig: bitcoin::Signature,
tx_refund_encsig: bitcoin::EncryptedSignature,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message4 {
tx_punish_sig: bitcoin::Signature,
tx_cancel_sig: bitcoin::Signature,
tx_early_refund_sig: bitcoin::Signature,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq)]
pub enum State {
Alice(AliceState),
Bob(BobState),
}
impl State {
pub fn swap_finished(&self) -> bool {
match self {
State::Alice(state) => alice_is_complete(state),
State::Bob(state) => bob_is_complete(state),
}
}
}
impl From<AliceState> for State {
fn from(alice: AliceState) -> Self {
Self::Alice(alice)
}
}
impl From<BobState> for State {
fn from(bob: BobState) -> Self {
Self::Bob(bob)
}
}
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("Not in the role of Alice")]
pub struct NotAlice;
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("Not in the role of Bob")]
pub struct NotBob;
impl TryInto<BobState> for State {
type Error = NotBob;
fn try_into(self) -> std::result::Result<BobState, Self::Error> {
match self {
State::Alice(_) => Err(NotBob),
State::Bob(state) => Ok(state),
}
}
}
impl TryInto<AliceState> for State {
type Error = NotAlice;
fn try_into(self) -> std::result::Result<AliceState, Self::Error> {
match self {
State::Alice(state) => Ok(state),
State::Bob(_) => Err(NotAlice),
}
}
}
#[async_trait]
pub trait Database {
async fn insert_peer_id(&self, swap_id: Uuid, peer_id: PeerId) -> Result<()>;
async fn get_peer_id(&self, swap_id: Uuid) -> Result<PeerId>;
async fn insert_monero_address_pool(
&self,
swap_id: Uuid,
address: MoneroAddressPool,
) -> Result<()>;
async fn get_monero_address_pool(&self, swap_id: Uuid) -> Result<MoneroAddressPool>;
async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>>;
async fn insert_address(&self, peer_id: PeerId, address: Multiaddr) -> Result<()>;
async fn get_addresses(&self, peer_id: PeerId) -> Result<Vec<Multiaddr>>;
async fn get_all_peer_addresses(&self) -> Result<Vec<(PeerId, Vec<Multiaddr>)>>;
async fn get_swap_start_date(&self, swap_id: Uuid) -> Result<String>;
async fn insert_latest_state(&self, swap_id: Uuid, state: State) -> Result<()>;
async fn get_state(&self, swap_id: Uuid) -> Result<State>;
async fn get_states(&self, swap_id: Uuid) -> Result<Vec<State>>;
async fn all(&self) -> Result<Vec<(Uuid, State)>>;
async fn insert_buffered_transfer_proof(
&self,
swap_id: Uuid,
proof: monero::TransferProof,
) -> Result<()>;
async fn get_buffered_transfer_proof(
&self,
swap_id: Uuid,
) -> Result<Option<monero::TransferProof>>;
}
pub use swap_machine::common::*;

View file

@ -1,22 +1,21 @@
//! Run an XMR/BTC swap in the role of Alice.
//! Alice holds XMR and wishes receive BTC.
pub use crate::protocol::alice::swap::*;
use crate::protocol::Database;
use crate::{asb, bitcoin, monero};
use crate::{asb, monero};
use bitcoin_wallet::BitcoinWallet;
use rust_decimal::Decimal;
use std::sync::Arc;
use swap_env::env::Config;
pub use swap_machine::alice::*;
use uuid::Uuid;
pub use self::state::*;
pub use self::swap::{run, run_until};
pub mod state;
pub mod swap;
pub struct Swap {
pub state: AliceState,
pub event_loop_handle: asb::EventLoopHandle,
pub bitcoin_wallet: Arc<bitcoin::Wallet>,
pub bitcoin_wallet: Arc<dyn BitcoinWallet>,
pub monero_wallet: Arc<monero::Wallets>,
pub env_config: Config,
pub developer_tip: TipConfig,

View file

@ -5,15 +5,17 @@ use std::sync::Arc;
use std::time::Duration;
use crate::asb::{EventLoopHandle, LatestRate};
use crate::bitcoin::ExpiredTimelocks;
use crate::common::retry;
use crate::monero;
use crate::monero::TransferProof;
use crate::protocol::alice::{AliceState, Swap, TipConfig};
use crate::{bitcoin, monero};
use ::bitcoin::consensus::encode::serialize_hex;
use anyhow::{bail, Context, Result};
use bitcoin_wallet::BitcoinWallet;
use rust_decimal::Decimal;
use swap_core::bitcoin::ExpiredTimelocks;
use swap_env::env::Config;
use swap_machine::alice::State3;
use tokio::select;
use tokio::time::timeout;
use uuid::Uuid;
@ -36,12 +38,12 @@ where
{
let mut current_state = swap.state;
while !is_complete(&current_state) && !exit_early(&current_state) {
while !swap_machine::alice::is_complete(&current_state) && !exit_early(&current_state) {
current_state = next_state(
swap.swap_id,
current_state,
&mut swap.event_loop_handle,
swap.bitcoin_wallet.as_ref(),
swap.bitcoin_wallet.clone(),
swap.monero_wallet.clone(),
&swap.env_config,
swap.developer_tip.clone(),
@ -61,7 +63,7 @@ async fn next_state<LR>(
swap_id: Uuid,
state: AliceState,
event_loop_handle: &mut EventLoopHandle,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
env_config: &Config,
developer_tip: TipConfig,
@ -78,7 +80,9 @@ where
Ok(match state {
AliceState::Started { state3 } => {
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
let tx_lock_status = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_lock.clone()))
.await;
match timeout(
env_config.bitcoin_lock_mempool_timeout,
@ -100,7 +104,9 @@ where
}
}
AliceState::BtcLockTransactionSeen { state3 } => {
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
let tx_lock_status = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_lock.clone()))
.await;
match timeout(
env_config.bitcoin_lock_confirmed_timeout,
@ -141,7 +147,7 @@ where
// because there is no way for the swap to succeed.
if !matches!(
state3
.expired_timelocks(bitcoin_wallet)
.expired_timelocks(&*bitcoin_wallet)
.await
.context("Failed to check for expired timelocks before locking Monero")
.map_err(backoff::Error::transient)?,
@ -238,7 +244,9 @@ where
let tx_early_refund_txid = tx_early_refund.compute_txid();
// Bob might cancel the swap and refund for himself. We won't need to early refund anymore.
let tx_cancel_status = bitcoin_wallet.subscribe_to(state3.tx_cancel()).await;
let tx_cancel_status = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_cancel()))
.await;
let backoff = backoff::ExponentialBackoffBuilder::new()
// We give up after 6 hours
@ -304,7 +312,7 @@ where
monero_wallet_restore_blockheight,
transfer_proof,
state3,
} => match state3.expired_timelocks(bitcoin_wallet).await? {
} => match state3.expired_timelocks(&*bitcoin_wallet).await? {
ExpiredTimelocks::None { .. } => {
tracing::info!("Locked Monero, waiting for confirmations");
monero_wallet
@ -343,7 +351,9 @@ where
transfer_proof,
state3,
} => {
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
let tx_lock_status = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_lock.clone()))
.await;
tokio::select! {
result = event_loop_handle.send_transfer_proof(transfer_proof.clone()) => {
@ -375,8 +385,9 @@ where
transfer_proof,
state3,
} => {
let tx_lock_status_subscription =
bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
let tx_lock_status_subscription = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_lock.clone()))
.await;
select! {
biased; // make sure the cancel timelock expiry future is polled first
@ -430,7 +441,9 @@ where
"Waiting for cancellation timelock to expire",
);
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
let tx_lock_status = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_lock.clone()))
.await;
tx_lock_status
.wait_until_confirmed_with(state3.cancel_timelock)
@ -454,7 +467,7 @@ where
match backoff::future::retry_notify(backoff.clone(), || async {
// If the cancel timelock is expired, there is no need to try to publish the redeem transaction anymore
if !matches!(
state3.expired_timelocks(bitcoin_wallet).await?,
state3.expired_timelocks(&*bitcoin_wallet).await?,
ExpiredTimelocks::None { .. }
) {
return Ok(None);
@ -502,7 +515,9 @@ where
}
}
AliceState::BtcRedeemTransactionPublished { state3, .. } => {
let subscription = bitcoin_wallet.subscribe_to(state3.tx_redeem()).await;
let subscription = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_redeem()))
.await;
match subscription.wait_until_final().await {
Ok(_) => AliceState::BtcRedeemed,
@ -516,13 +531,17 @@ where
transfer_proof,
state3,
} => {
if state3.check_for_tx_cancel(bitcoin_wallet).await?.is_none() {
if state3
.check_for_tx_cancel(&*bitcoin_wallet)
.await?
.is_none()
{
// If Bob hasn't yet broadcasted the cancel transaction, Alice has to publish it
// to be able to eventually punish. Since the punish timelock is
// relative to the publication of the cancel transaction we have to ensure it
// gets published once the cancel timelock expires.
if let Err(e) = state3.submit_tx_cancel(bitcoin_wallet).await {
if let Err(e) = state3.submit_tx_cancel(&*bitcoin_wallet).await {
// TODO: Actually ensure the transaction is published
// What about a wrapper function ensure_tx_published that repeats the tx submission until
// our subscription sees it in the mempool?
@ -545,10 +564,12 @@ where
transfer_proof,
state3,
} => {
let tx_cancel_status = bitcoin_wallet.subscribe_to(state3.tx_cancel()).await;
let tx_cancel_status = bitcoin_wallet
.subscribe_to(Box::new(state3.tx_cancel()))
.await;
select! {
spend_key = state3.watch_for_btc_tx_refund(bitcoin_wallet) => {
spend_key = state3.watch_for_btc_tx_refund(&*bitcoin_wallet) => {
let spend_key = spend_key?;
AliceState::BtcRefunded {
@ -603,7 +624,7 @@ where
} => {
// TODO: We should retry indefinitely here until we find the refund transaction
// TODO: If we crash while we are waiting for the punish_tx to be confirmed (punish_btc waits until confirmation), we will remain in this state forever because we will attempt to re-publish the punish transaction
let punish = state3.punish_btc(bitcoin_wallet).await;
let punish = state3.punish_btc(&*bitcoin_wallet).await;
match punish {
Ok(_) => AliceState::BtcPunished {
@ -653,15 +674,84 @@ where
})
}
pub fn is_complete(state: &AliceState) -> bool {
matches!(
state,
AliceState::XmrRefunded
| AliceState::BtcRedeemed
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted
| AliceState::BtcEarlyRefunded(_)
)
#[allow(async_fn_in_trait)]
pub trait XmrRefundable {
async fn refund_xmr(
&self,
monero_wallet: Arc<monero::Wallets>,
swap_id: Uuid,
spend_key: monero::PrivateKey,
transfer_proof: TransferProof,
) -> Result<()>;
}
impl XmrRefundable for State3 {
async fn refund_xmr(
&self,
monero_wallet: Arc<monero::Wallets>,
swap_id: Uuid,
spend_key: monero::PrivateKey,
transfer_proof: TransferProof,
) -> Result<()> {
let view_key = self.v;
// Ensure that the XMR to be refunded are spendable by awaiting 10 confirmations
// on the lock transaction.
tracing::info!("Waiting for Monero lock transaction to be confirmed");
let transfer_proof_2 = transfer_proof.clone();
monero_wallet
.wait_until_confirmed(
self.lock_xmr_watch_request(transfer_proof_2, 10),
Some(move |(confirmations, target_confirmations)| {
tracing::debug!(
%confirmations,
%target_confirmations,
"Monero lock transaction got a confirmation"
);
}),
)
.await
.context("Failed to wait for Monero lock transaction to be confirmed")?;
tracing::info!("Refunding Monero");
tracing::debug!(%swap_id, "Opening temporary Monero wallet from keys");
let swap_wallet = monero_wallet
.swap_wallet(swap_id, spend_key, view_key, transfer_proof.tx_hash())
.await
.context(format!("Failed to open/create swap wallet `{}`", swap_id))?;
// Update blockheight to ensure that the wallet knows the funds are unlocked
tracing::debug!(%swap_id, "Updating temporary Monero wallet's blockheight");
let _ = swap_wallet
.blockchain_height()
.await
.context("Couldn't get Monero blockheight")?;
tracing::debug!(%swap_id, "Sweeping Monero to redeem address");
let main_address = monero_wallet.main_wallet().await.main_address().await;
swap_wallet
.sweep(&main_address)
.await
.context("Failed to sweep Monero to redeem address")?;
Ok(())
}
}
impl XmrRefundable for Box<State3> {
async fn refund_xmr(
&self,
monero_wallet: Arc<monero::Wallets>,
swap_id: Uuid,
spend_key: monero::PrivateKey,
transfer_proof: TransferProof,
) -> Result<()> {
(**self)
.refund_xmr(monero_wallet, swap_id, spend_key, transfer_proof)
.await
}
}
/// Build transfer destinations for the Monero lock transaction, optionally including a developer tip.

View file

@ -1,26 +1,28 @@
use std::sync::Arc;
use anyhow::Result;
use bitcoin_wallet::BitcoinWallet;
use std::convert::TryInto;
use uuid::Uuid;
use crate::cli::api::tauri_bindings::TauriHandle;
use crate::monero::MoneroAddressPool;
use crate::protocol::Database;
use crate::{bitcoin, cli, monero};
use crate::{cli, monero};
use swap_core::bitcoin;
use swap_env::env;
pub use self::state::*;
pub use self::swap::{run, run_until};
use std::convert::TryInto;
pub use crate::protocol::bob::swap::*;
pub use swap_machine::bob::*;
pub mod state;
pub mod swap;
pub struct Swap {
pub state: BobState,
pub event_loop_handle: cli::EventLoopHandle,
pub db: Arc<dyn Database + Send + Sync>,
pub bitcoin_wallet: Arc<bitcoin::Wallet>,
pub bitcoin_wallet: Arc<dyn BitcoinWallet>,
pub monero_wallet: Arc<monero::Wallets>,
pub env_config: env::Config,
pub id: Uuid,
@ -33,7 +35,7 @@ impl Swap {
pub fn new(
db: Arc<dyn Database + Send + Sync>,
id: Uuid,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
env_config: env::Config,
event_loop_handle: cli::EventLoopHandle,
@ -63,7 +65,7 @@ impl Swap {
pub async fn from_db(
db: Arc<dyn Database + Send + Sync>,
id: Uuid,
bitcoin_wallet: Arc<bitcoin::Wallet>,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
env_config: env::Config,
event_loop_handle: cli::EventLoopHandle,

View file

@ -1,34 +1,25 @@
use crate::bitcoin::wallet::ScriptStatus;
use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund};
use crate::cli::api::tauri_bindings::LockBitcoinDetails;
use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent};
use crate::cli::EventLoopHandle;
use crate::common::retry;
use crate::monero;
use crate::monero::MoneroAddressPool;
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
use crate::network::swap_setup::bob::NewSwap;
use crate::protocol::bob::state::*;
use crate::protocol::bob::*;
use crate::protocol::{bob, Database};
use crate::{bitcoin, monero};
use anyhow::{bail, Context as AnyContext, Result};
use std::sync::Arc;
use std::time::Duration;
use swap_core::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund};
use swap_core::monero::TxHash;
use swap_env::env;
use swap_machine::bob::State5;
use tokio::select;
use uuid::Uuid;
const PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS: u64 = 60 * 3;
pub fn is_complete(state: &BobState) -> bool {
matches!(
state,
BobState::BtcRefunded(..)
| BobState::BtcEarlyRefunded { .. }
| BobState::XmrRedeemed { .. }
| BobState::SafelyAborted
)
}
/// Identifies states that have already processed the transfer proof.
/// This is used to be able to acknowledge the transfer proof multiple times (if it was already processed).
/// This is necessary because sometimes our acknowledgement might not reach Alice.
@ -73,7 +64,7 @@ pub async fn run_until(
current_state.clone(),
&mut swap.event_loop_handle,
swap.db.clone(),
swap.bitcoin_wallet.as_ref(),
swap.bitcoin_wallet.clone(),
swap.monero_wallet.clone(),
swap.monero_receive_pool.clone(),
swap.event_emitter.clone(),
@ -101,7 +92,7 @@ async fn next_state(
state: BobState,
event_loop_handle: &mut EventLoopHandle,
db: Arc<dyn Database + Send + Sync>,
bitcoin_wallet: &bitcoin::Wallet,
bitcoin_wallet: Arc<dyn BitcoinWallet>,
monero_wallet: Arc<monero::Wallets>,
monero_receive_pool: MoneroAddressPool,
event_emitter: Option<TauriHandle>,
@ -267,16 +258,19 @@ async fn next_state(
},
);
let (tx_early_refund_status, tx_lock_status) = tokio::join!(
bitcoin_wallet.subscribe_to(state3.construct_tx_early_refund()),
bitcoin_wallet.subscribe_to(state3.tx_lock.clone())
let (tx_early_refund_status, tx_lock_status): (
bitcoin_wallet::Subscription,
bitcoin_wallet::Subscription,
) = tokio::join!(
bitcoin_wallet.subscribe_to(Box::new(state3.construct_tx_early_refund())),
bitcoin_wallet.subscribe_to(Box::new(state3.tx_lock.clone()))
);
// Check explicitly whether the cancel timelock has expired
// (Most likely redundant but cannot hurt)
// We only warn if this fails
if let Ok(true) = state3
.expired_timelock(bitcoin_wallet)
.expired_timelock(&*bitcoin_wallet)
.await
.inspect_err(|err| {
tracing::warn!(?err, "Failed to check for cancel timelock expiration");
@ -291,7 +285,7 @@ async fn next_state(
// (Most likely redundant because we already do this below but cannot hurt)
// We only warn if this fail here
if let Ok(Some(_)) = state3
.check_for_tx_early_refund(bitcoin_wallet)
.check_for_tx_early_refund(&*bitcoin_wallet)
.await
.inspect_err(|err| {
tracing::warn!(?err, "Failed to check for early refund transaction");
@ -324,7 +318,7 @@ async fn next_state(
let cancel_timelock_expires = tx_lock_status.wait_until(|status| {
// Emit a tauri event on new confirmations
match status {
ScriptStatus::Confirmed(confirmed) => {
bitcoin_wallet::primitives::ScriptStatus::Confirmed(confirmed) => {
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::BtcLockTxInMempool {
@ -333,7 +327,7 @@ async fn next_state(
},
);
}
ScriptStatus::InMempool => {
bitcoin_wallet::primitives::ScriptStatus::InMempool => {
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::BtcLockTxInMempool {
@ -342,7 +336,8 @@ async fn next_state(
},
);
}
ScriptStatus::Unseen | ScriptStatus::Retrying => {
bitcoin_wallet::primitives::ScriptStatus::Unseen
| bitcoin_wallet::primitives::ScriptStatus::Retrying => {
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::BtcLockTxInMempool {
@ -402,7 +397,7 @@ async fn next_state(
// Check if the cancel timelock has expired
// If it has, we have to cancel the swap
if state
.expired_timelock(bitcoin_wallet)
.expired_timelock(&*bitcoin_wallet)
.await?
.cancel_timelock_expired()
{
@ -413,9 +408,12 @@ async fn next_state(
let tx_early_refund = state.construct_tx_early_refund();
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
bitcoin_wallet.subscribe_to(tx_early_refund.clone())
let (tx_lock_status, tx_early_refund_status): (
bitcoin_wallet::Subscription,
bitcoin_wallet::Subscription,
) = tokio::join!(
bitcoin_wallet.subscribe_to(Box::new(state.tx_lock.clone())),
bitcoin_wallet.subscribe_to(Box::new(tx_early_refund.clone()))
);
// Clone these so that we can move them into the listener closure
@ -485,22 +483,25 @@ async fn next_state(
// In case we send the encrypted signature to Alice, but she doesn't give us a confirmation
// We need to check if she still published the Bitcoin redeem transaction
// Otherwise we risk staying stuck in "XmrLocked"
if let Some(state5) = state.check_for_tx_redeem(bitcoin_wallet).await? {
if let Some(state5) = state.check_for_tx_redeem(&*bitcoin_wallet).await? {
return Ok(BobState::BtcRedeemed(state5));
}
// Check whether we can cancel the swap and do so if possible.
if state
.expired_timelock(bitcoin_wallet)
.expired_timelock(&*bitcoin_wallet)
.await?
.cancel_timelock_expired()
{
return Ok(BobState::CancelTimelockExpired(state.cancel()));
}
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
bitcoin_wallet.subscribe_to(state.construct_tx_early_refund())
let (tx_lock_status, tx_early_refund_status): (
bitcoin_wallet::Subscription,
bitcoin_wallet::Subscription,
) = tokio::join!(
bitcoin_wallet.subscribe_to(Box::new(state.tx_lock.clone())),
bitcoin_wallet.subscribe_to(Box::new(state.construct_tx_early_refund()))
);
// Alice has locked her Monero
@ -538,27 +539,30 @@ async fn next_state(
// We need to make sure that Alice did not publish the redeem transaction while we were offline
// Even if the cancel timelock expired, if Alice published the redeem transaction while we were away we cannot miss it
// If we do we cannot refund and will never be able to leave the "CancelTimelockExpired" state
if let Some(state5) = state.check_for_tx_redeem(bitcoin_wallet).await? {
if let Some(state5) = state.check_for_tx_redeem(&*bitcoin_wallet).await? {
return Ok(BobState::BtcRedeemed(state5));
}
if state
.expired_timelock(bitcoin_wallet)
.expired_timelock(&*bitcoin_wallet)
.await?
.cancel_timelock_expired()
{
return Ok(BobState::CancelTimelockExpired(state.cancel()));
}
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
bitcoin_wallet.subscribe_to(state.construct_tx_early_refund())
let (tx_lock_status, tx_early_refund_status): (
bitcoin_wallet::Subscription,
bitcoin_wallet::Subscription,
) = tokio::join!(
bitcoin_wallet.subscribe_to(Box::new(state.tx_lock.clone())),
bitcoin_wallet.subscribe_to(Box::new(state.construct_tx_early_refund()))
);
select! {
// Wait for Alice to redeem the Bitcoin
// We can then extract the key and redeem our Monero
state5 = state.watch_for_redeem_btc(bitcoin_wallet) => {
state5 = state.watch_for_redeem_btc(&*bitcoin_wallet) => {
BobState::BtcRedeemed(state5?)
},
// Wait for the cancel timelock to expire
@ -613,6 +617,7 @@ async fn next_state(
"Redeeming Monero",
|| async {
state
.clone()
.redeem_xmr(&monero_wallet, swap_id, monero_receive_pool.clone())
.await
.map_err(backoff::Error::transient)
@ -639,16 +644,20 @@ async fn next_state(
event_emitter
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::CancelTimelockExpired);
if state6.check_for_tx_cancel(bitcoin_wallet).await?.is_none() {
if state6
.check_for_tx_cancel(&*bitcoin_wallet)
.await?
.is_none()
{
tracing::debug!("Couldn't find tx_cancel yet, publishing ourselves");
if let Err(tx_cancel_err) = state6.submit_tx_cancel(bitcoin_wallet).await {
if let Err(tx_cancel_err) = state6.submit_tx_cancel(&*bitcoin_wallet).await {
tracing::warn!(err = %tx_cancel_err, "Failed to publish tx_cancel even though it is not present in the chain. Did Alice already refund us our Bitcoin early?");
// If tx_cancel is not present in the chain and we fail to publish it. There's only one logical conclusion:
// The tx_lock UTXO has been spent by the tx_early_refund transaction
// Therefore we check for the early refund transaction
match state6.check_for_tx_early_refund(bitcoin_wallet).await? {
match state6.check_for_tx_early_refund(&*bitcoin_wallet).await? {
Some(_) => {
return Ok(BobState::BtcEarlyRefundPublished(state6));
}
@ -670,14 +679,14 @@ async fn next_state(
);
// Bob has cancelled the swap
match state.expired_timelock(bitcoin_wallet).await? {
match state.expired_timelock(&*bitcoin_wallet).await? {
ExpiredTimelocks::None { .. } => {
bail!(
"Internal error: canceled state reached before cancel timelock was expired"
);
}
ExpiredTimelocks::Cancel { .. } => {
let btc_refund_txid = state.publish_refund_btc(bitcoin_wallet).await?;
let btc_refund_txid = state.publish_refund_btc(&*bitcoin_wallet).await?;
tracing::info!(%btc_refund_txid, "Refunded our Bitcoin");
@ -702,9 +711,12 @@ async fn next_state(
let tx_refund = state.construct_tx_refund()?;
let tx_early_refund = state.construct_tx_early_refund();
let (tx_refund_status, tx_early_refund_status) = tokio::join!(
bitcoin_wallet.subscribe_to(tx_refund.clone()),
bitcoin_wallet.subscribe_to(tx_early_refund.clone()),
let (tx_refund_status, tx_early_refund_status): (
bitcoin_wallet::Subscription,
bitcoin_wallet::Subscription,
) = tokio::join!(
bitcoin_wallet.subscribe_to(Box::new(tx_refund.clone())),
bitcoin_wallet.subscribe_to(Box::new(tx_early_refund.clone())),
);
// Either of these two refund transactions could have been published
@ -753,9 +765,12 @@ async fn next_state(
);
// Wait for confirmations
let (tx_lock_status, tx_early_refund_status) = tokio::join!(
bitcoin_wallet.subscribe_to(state.tx_lock.clone()),
bitcoin_wallet.subscribe_to(tx_early_refund_tx.clone()),
let (tx_lock_status, tx_early_refund_status): (
bitcoin_wallet::Subscription,
bitcoin_wallet::Subscription,
) = tokio::join!(
bitcoin_wallet.subscribe_to(Box::new(state.tx_lock.clone())),
bitcoin_wallet.subscribe_to(Box::new(tx_early_refund_tx.clone())),
);
select! {
@ -852,6 +867,7 @@ async fn next_state(
"Redeeming Monero",
|| async {
state5
.clone()
.redeem_xmr(&monero_wallet, swap_id, monero_receive_pool.clone())
.await
.map_err(backoff::Error::transient)
@ -941,3 +957,63 @@ async fn next_state(
}
})
}
trait XmrRedeemable {
async fn redeem_xmr(
self,
monero_wallet: &monero::Wallets,
swap_id: Uuid,
monero_receive_pool: MoneroAddressPool,
) -> Result<Vec<TxHash>>;
}
impl XmrRedeemable for State5 {
async fn redeem_xmr(
self: State5,
monero_wallet: &monero::Wallets,
swap_id: Uuid,
monero_receive_pool: MoneroAddressPool,
) -> Result<Vec<TxHash>> {
let (spend_key, view_key) = self.xmr_keys();
tracing::info!(%swap_id, "Redeeming Monero from extracted keys");
tracing::debug!(%swap_id, "Opening temporary Monero wallet");
let wallet = monero_wallet
.swap_wallet(
swap_id,
spend_key,
view_key,
self.lock_transfer_proof.tx_hash(),
)
.await
.context("Failed to open Monero wallet")?;
// Update blockheight to ensure that the wallet knows the funds are unlocked
tracing::debug!(%swap_id, "Updating temporary Monero wallet's blockheight");
let _ = wallet
.blockchain_height()
.await
.context("Couldn't get Monero blockheight")?;
tracing::debug!(%swap_id, receive_address=?monero_receive_pool, "Sweeping Monero to receive address");
let main_address = monero_wallet.main_wallet().await.main_address().await;
let tx_hashes = wallet
.sweep_multi_destination(
&monero_receive_pool.fill_empty_addresses(main_address),
&monero_receive_pool.percentages(),
)
.await
.context("Failed to redeem Monero")?
.into_iter()
.map(|tx_receipt| TxHash(tx_receipt.txid))
.collect();
tracing::info!(%swap_id, txids=?tx_hashes, "Monero sweep completed");
Ok(tx_hashes)
}
}

View file

@ -40,7 +40,7 @@ async fn given_alice_and_bob_manually_refund_after_funds_locked_both_refund() {
if let BobState::BtcLocked { state3, .. } = bob_swap.state.clone() {
bob_swap
.bitcoin_wallet
.subscribe_to(state3.tx_lock)
.subscribe_to(Box::new(state3.tx_lock))
.await
.wait_until_confirmed_with(state3.cancel_timelock)
.await?;

View file

@ -40,7 +40,7 @@ async fn given_alice_and_bob_manually_cancel_and_refund_after_funds_locked_both_
if let BobState::BtcLocked { state3, .. } = bob_swap.state.clone() {
bob_swap
.bitcoin_wallet
.subscribe_to(state3.tx_lock)
.subscribe_to(Box::new(state3.tx_lock))
.await
.wait_until_confirmed_with(state3.cancel_timelock)
.await?;

View file

@ -36,7 +36,7 @@ async fn alice_manually_punishes_after_bob_dead() {
// Ensure cancel timelock is expired
if let AliceState::XmrLockTransactionSent { state3, .. } = alice_state {
alice_bitcoin_wallet
.subscribe_to(state3.tx_lock)
.subscribe_to(Box::new(state3.tx_lock))
.await
.wait_until_confirmed_with(state3.cancel_timelock)
.await?;
@ -54,7 +54,7 @@ async fn alice_manually_punishes_after_bob_dead() {
// Ensure punish timelock is expired
if let AliceState::BtcCancelled { state3, .. } = alice_state {
alice_bitcoin_wallet
.subscribe_to(state3.tx_cancel())
.subscribe_to(Box::new(state3.tx_cancel()))
.await
.wait_until_confirmed_with(state3.punish_timelock)
.await?;

View file

@ -36,7 +36,7 @@ async fn alice_manually_punishes_after_bob_dead_and_bob_cancels() {
// Ensure cancel timelock is expired
if let AliceState::XmrLockTransactionSent { state3, .. } = alice_state {
alice_bitcoin_wallet
.subscribe_to(state3.tx_lock)
.subscribe_to(Box::new(state3.tx_lock))
.await
.wait_until_confirmed_with(state3.cancel_timelock)
.await?;
@ -54,7 +54,7 @@ async fn alice_manually_punishes_after_bob_dead_and_bob_cancels() {
// Ensure punish timelock is expired
if let AliceState::BtcCancelled { state3, .. } = alice_state {
alice_bitcoin_wallet
.subscribe_to(state3.tx_cancel())
.subscribe_to(Box::new(state3.tx_cancel()))
.await
.wait_until_confirmed_with(state3.punish_timelock)
.await?;

View file

@ -35,7 +35,7 @@ async fn alice_punishes_after_restart_if_bob_dead() {
// cancel transaction is not published at this point)
if let AliceState::XmrLockTransactionSent { state3, .. } = alice_state {
alice_bitcoin_wallet
.subscribe_to(state3.tx_lock)
.subscribe_to(Box::new(state3.tx_lock))
.await
.wait_until_confirmed_with(state3.cancel_timelock)
.await?;

View file

@ -1,27 +0,0 @@
#!/bin/bash
set -euxo pipefail
VERSION=1.0.0-rc.19
mkdir bdk
stat ./target/debug/swap || exit 1
cp ./target/debug/swap bdk/swap-current
pushd bdk
echo "download swap $VERSION"
curl -L "https://github.com/eigenwallet/core/releases/download/${VERSION}/swap_${VERSION}_Linux_x86_64.tar" | tar xv
echo "create testnet wallet with $VERSION"
./swap --testnet --data-base-dir . --debug balance || exit 1
echo "check testnet wallet with this version"
./swap-current --testnet --data-base-dir . --debug balance || exit 1
echo "create mainnet wallet with $VERSION"
./swap --version || exit 1
./swap --data-base-dir . --debug balance || exit 1
echo "check mainnet wallet with this version"
./swap-current --version || exit 1
./swap-current --data-base-dir . --debug balance || exit 1
exit 0

View file

@ -26,7 +26,7 @@ async fn given_bob_restarts_while_alice_redeems_btc() {
if let BobState::EncSigSent(state4) = bob_swap.state.clone() {
bob_swap
.bitcoin_wallet
.subscribe_to(state4.tx_lock)
.subscribe_to(Box::new(state4.tx_lock))
.await
.wait_until_confirmed_with(state4.cancel_timelock)
.await?;

View file

@ -2,7 +2,7 @@
// MIT License
use std::pin::Pin;
use std::sync::{mpsc, Arc, Mutex};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{self, /* SystemTime, UNIX_EPOCH, */ Duration};
pub fn throttle<F, T>(closure: F, delay: Duration) -> Throttle<T>