diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cfa28fba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + push: + branches: + - 'staging' + - 'trying' + - 'master' + +jobs: + static_analysis: + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + override: true + components: rustfmt, clippy + + - name: Cache ~/.cargo/bin directory + uses: actions/cache@v1 + with: + path: ~/.cargo/bin + key: ubuntu-rust-${{ env.RUST_TOOLCHAIN }}-cargo-bin-directory-v1 + + - name: Install tomlfmt + run: which cargo-tomlfmt || cargo install cargo-tomlfmt + + - name: Check Cargo.toml formatting + run: | + cargo tomlfmt -d -p Cargo.toml + cargo tomlfmt -d -p xmr-btc/Cargo.toml + cargo tomlfmt -d -p monero-harness/Cargo.toml + + - name: Check code formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + build_test: + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + override: true + + - name: Cache target directory + uses: actions/cache@v1 + with: + path: target + key: rust-${{ matrix.rust_toolchain }}-target-directory-${{ hashFiles('Cargo.lock') }}-v1 + + - name: Cache ~/.cargo/registry directory + uses: actions/cache@v1 + with: + path: ~/.cargo/registry + key: rust-${{ matrix.rust_toolchain }}-cargo-registry-directory-${{ hashFiles('Cargo.lock') }}-v1 + + - name: Cargo check release code with default features + run: cargo check --workspace + + - name: Cargo check all features + run: cargo check --workspace --all-targets --all-features + + - name: Cargo test + run: cargo test --workspace --all-features diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..e0174381 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["monero-harness", "xmr-btc"] diff --git a/bors.toml b/bors.toml new file mode 100644 index 00000000..e5414c01 --- /dev/null +++ b/bors.toml @@ -0,0 +1,4 @@ +status = [ + "static_analysis", + "build_test", +] diff --git a/monero-harness/Cargo.toml b/monero-harness/Cargo.toml new file mode 100644 index 00000000..00256c3a --- /dev/null +++ b/monero-harness/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "monero-harness" +version = "0.1.0" +authors = ["CoBloX Team "] +edition = "2018" + +[dependencies] +anyhow = "1" +futures = "0.3" +rand = "0.7" +reqwest = { version = "0.10", default-features = false, features = ["json", "native-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +spectral = "0.6" +testcontainers = "0.10" +tokio = { version = "0.2", default-features = false, features = ["blocking", "macros", "rt-core", "time"] } +tracing = "0.1" +url = "2" diff --git a/monero-harness/LICENSE b/monero-harness/LICENSE new file mode 100644 index 00000000..7a80c209 --- /dev/null +++ b/monero-harness/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 CoBloX + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/monero-harness/README.md b/monero-harness/README.md new file mode 100644 index 00000000..ee15783a --- /dev/null +++ b/monero-harness/README.md @@ -0,0 +1,12 @@ +Monero Harness +============== + +Provides an implementation of `testcontainers::Image` for a monero image to run +`monerod` and `monero-wallet-rpc` in a docker container. + +Also provides two standalone JSON RPC clients, one each for `monerod` and `monero-wallet-rpc`. + +Example Usage +------------- + +Please see `tests/*` for example usage. diff --git a/monero-harness/rust-toolchain b/monero-harness/rust-toolchain new file mode 100644 index 00000000..0a3db35b --- /dev/null +++ b/monero-harness/rust-toolchain @@ -0,0 +1 @@ +1.46.0 diff --git a/monero-harness/rustfmt.toml b/monero-harness/rustfmt.toml new file mode 100644 index 00000000..100f5106 --- /dev/null +++ b/monero-harness/rustfmt.toml @@ -0,0 +1,9 @@ +edition = "2018" +condense_wildcard_suffixes = true +format_macro_matchers = true +merge_imports = true +use_field_init_shorthand = true +format_code_in_doc_comments = true +normalize_comments = true +wrap_comments = true +overflow_delimited_expr = true diff --git a/monero-harness/src/image.rs b/monero-harness/src/image.rs new file mode 100644 index 00000000..1998b4bc --- /dev/null +++ b/monero-harness/src/image.rs @@ -0,0 +1,293 @@ +use std::{collections::HashMap, env::var, thread::sleep, time::Duration}; +use testcontainers::{ + core::{Container, Docker, Port, WaitForMessage}, + Image, +}; + +pub const MONEROD_RPC_PORT: u16 = 48081; +pub const MINER_WALLET_RPC_PORT: u16 = 48083; +pub const ALICE_WALLET_RPC_PORT: u16 = 48084; +pub const BOB_WALLET_RPC_PORT: u16 = 48085; + +#[derive(Debug)] +pub struct Monero { + tag: String, + args: Args, + ports: Option>, + entrypoint: Option, +} + +impl Image for Monero { + type Args = Args; + type EnvVars = HashMap; + type Volumes = HashMap; + type EntryPoint = str; + + fn descriptor(&self) -> String { + format!("xmrto/monero:{}", self.tag) + } + + fn wait_until_ready(&self, container: &Container<'_, D, Self>) { + container + .logs() + .stdout + .wait_for_message( + "The daemon is running offline and will not attempt to sync to the Monero network", + ) + .unwrap(); + + let additional_sleep_period = + var("MONERO_ADDITIONAL_SLEEP_PERIOD").map(|value| value.parse()); + + if let Ok(Ok(sleep_period)) = additional_sleep_period { + let sleep_period = Duration::from_millis(sleep_period); + + sleep(sleep_period) + } + } + + fn args(&self) -> ::Args { + self.args.clone() + } + + fn volumes(&self) -> Self::Volumes { + HashMap::new() + } + + fn env_vars(&self) -> Self::EnvVars { + HashMap::new() + } + + fn ports(&self) -> Option> { + self.ports.clone() + } + + fn with_args(self, args: ::Args) -> Self { + Monero { args, ..self } + } + + fn with_entrypoint(self, entrypoint: &Self::EntryPoint) -> Self { + Self { + entrypoint: Some(entrypoint.to_string()), + ..self + } + } + + fn entrypoint(&self) -> Option { + self.entrypoint.to_owned() + } +} + +impl Default for Monero { + fn default() -> Self { + Monero { + tag: "v0.16.0.3".into(), + args: Args::default(), + ports: None, + entrypoint: Some("".into()), + } + } +} + +impl Monero { + pub fn with_tag(self, tag_str: &str) -> Self { + Monero { + tag: tag_str.to_string(), + ..self + } + } + + pub fn with_mapped_port>(mut self, port: P) -> Self { + let mut ports = self.ports.unwrap_or_default(); + ports.push(port.into()); + self.ports = Some(ports); + self + } + + pub fn with_wallet(self, name: &str, rpc_port: u16) -> Self { + let wallet = WalletArgs::new(name, rpc_port); + let mut wallet_args = self.args.wallets; + wallet_args.push(wallet); + Self { + args: Args { + monerod: self.args.monerod, + wallets: wallet_args, + }, + ..self + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct Args { + monerod: MonerodArgs, + wallets: Vec, +} + +#[derive(Debug, Clone)] +pub struct MonerodArgs { + pub regtest: bool, + pub offline: bool, + pub rpc_payment_allow_free_loopback: bool, + pub confirm_external_bind: bool, + pub non_interactive: bool, + pub no_igd: bool, + pub hide_my_port: bool, + pub rpc_bind_ip: String, + pub rpc_bind_port: u16, + pub fixed_difficulty: u32, + pub data_dir: String, +} + +#[derive(Debug, Clone)] +pub struct WalletArgs { + pub disable_rpc_login: bool, + pub confirm_external_bind: bool, + pub wallet_dir: String, + pub rpc_bind_ip: String, + pub rpc_bind_port: u16, + pub daemon_address: String, + pub log_level: u32, +} + +/// Sane defaults for a mainnet regtest instance. +impl Default for MonerodArgs { + fn default() -> Self { + MonerodArgs { + regtest: true, + offline: true, + rpc_payment_allow_free_loopback: true, + confirm_external_bind: true, + non_interactive: true, + no_igd: true, + hide_my_port: true, + rpc_bind_ip: "0.0.0.0".to_string(), + rpc_bind_port: MONEROD_RPC_PORT, + fixed_difficulty: 1, + data_dir: "/monero".to_string(), + } + } +} + +impl MonerodArgs { + // Return monerod args as is single string so we can pass it to bash. + fn args(&self) -> String { + let mut args = vec!["monerod".to_string()]; + + if self.regtest { + args.push("--regtest".to_string()) + } + + if self.offline { + args.push("--offline".to_string()) + } + + if self.rpc_payment_allow_free_loopback { + args.push("--rpc-payment-allow-free-loopback".to_string()) + } + + if self.confirm_external_bind { + args.push("--confirm-external-bind".to_string()) + } + + if self.non_interactive { + args.push("--non-interactive".to_string()) + } + + if self.no_igd { + args.push("--no-igd".to_string()) + } + + if self.hide_my_port { + args.push("--hide-my-port".to_string()) + } + + if !self.rpc_bind_ip.is_empty() { + args.push(format!("--rpc-bind-ip {}", self.rpc_bind_ip)); + } + + if self.rpc_bind_port != 0 { + args.push(format!("--rpc-bind-port {}", self.rpc_bind_port)); + } + + if !self.data_dir.is_empty() { + args.push(format!("--data-dir {}", self.data_dir)); + } + + if self.fixed_difficulty != 0 { + args.push(format!("--fixed-difficulty {}", self.fixed_difficulty)); + } + + args.join(" ") + } +} + +impl WalletArgs { + pub fn new(wallet_dir: &str, rpc_port: u16) -> Self { + let daemon_address = format!("localhost:{}", MONEROD_RPC_PORT); + WalletArgs { + disable_rpc_login: true, + confirm_external_bind: true, + wallet_dir: wallet_dir.into(), + rpc_bind_ip: "0.0.0.0".into(), + rpc_bind_port: rpc_port, + daemon_address, + log_level: 4, + } + } + + // Return monero-wallet-rpc args as is single string so we can pass it to bash. + fn args(&self) -> String { + let mut args = vec!["monero-wallet-rpc".to_string()]; + + if self.disable_rpc_login { + args.push("--disable-rpc-login".to_string()) + } + + if self.confirm_external_bind { + args.push("--confirm-external-bind".to_string()) + } + + if !self.wallet_dir.is_empty() { + args.push(format!("--wallet-dir {}", self.wallet_dir)); + } + + if !self.rpc_bind_ip.is_empty() { + args.push(format!("--rpc-bind-ip {}", self.rpc_bind_ip)); + } + + if self.rpc_bind_port != 0 { + args.push(format!("--rpc-bind-port {}", self.rpc_bind_port)); + } + + if !self.daemon_address.is_empty() { + args.push(format!("--daemon-address {}", self.daemon_address)); + } + + if self.log_level != 0 { + args.push(format!("--log-level {}", self.log_level)); + } + + args.join(" ") + } +} + +impl IntoIterator for Args { + type Item = String; + type IntoIter = ::std::vec::IntoIter; + + fn into_iter(self) -> ::IntoIter { + let mut args = Vec::new(); + + args.push("/bin/bash".into()); + args.push("-c".into()); + + let wallet_args: Vec = self.wallets.iter().map(|wallet| wallet.args()).collect(); + let wallet_args = wallet_args.join(" & "); + + let cmd = format!("{} & {} ", self.monerod.args(), wallet_args); + args.push(cmd); + + args.into_iter() + } +} diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs new file mode 100644 index 00000000..128d9d97 --- /dev/null +++ b/monero-harness/src/lib.rs @@ -0,0 +1,296 @@ +#![warn( + unused_extern_crates, + missing_debug_implementations, + missing_copy_implementations, + rust_2018_idioms, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::fallible_impl_from, + clippy::cast_precision_loss, + clippy::cast_possible_wrap, + clippy::dbg_macro +)] +#![forbid(unsafe_code)] + +//! # monero-harness +//! +//! A simple lib to start a monero container (incl. monerod and +//! monero-wallet-rpc). Provides initialisation methods to generate blocks, +//! create and fund accounts, and start a continuous mining task mining blocks +//! every BLOCK_TIME_SECS seconds. +//! +//! Also provides standalone JSON RPC clients for monerod and monero-wallet-rpc. + +pub mod image; +pub mod rpc; + +use anyhow::Result; +use rand::Rng; +use serde::Deserialize; +use std::time::Duration; +use testcontainers::{clients::Cli, core::Port, Container, Docker}; +use tokio::time; + +use crate::{ + image::{ALICE_WALLET_RPC_PORT, BOB_WALLET_RPC_PORT, MINER_WALLET_RPC_PORT, MONEROD_RPC_PORT}, + rpc::{ + monerod, + wallet::{self, GetAddress, Transfer}, + }, +}; + +/// How often we mine a block. +const BLOCK_TIME_SECS: u64 = 1; + +/// Poll interval when checking if the wallet has synced with monerod. +const WAIT_WALLET_SYNC_MILLIS: u64 = 1000; + +/// Wallet sub-account indices. +const ACCOUNT_INDEX_PRIMARY: u32 = 0; + +#[derive(Debug)] +pub struct Monero<'c> { + pub docker: Container<'c, Cli, image::Monero>, + pub monerod_rpc_port: u16, + pub miner_wallet_rpc_port: u16, + pub alice_wallet_rpc_port: u16, + pub bob_wallet_rpc_port: u16, +} + +impl<'c> Monero<'c> { + /// Starts a new regtest monero container. + pub fn new(cli: &'c Cli) -> Self { + let mut rng = rand::thread_rng(); + let monerod_rpc_port: u16 = rng.gen_range(1024, u16::MAX); + let miner_wallet_rpc_port: u16 = rng.gen_range(1024, u16::MAX); + let alice_wallet_rpc_port: u16 = rng.gen_range(1024, u16::MAX); + let bob_wallet_rpc_port: u16 = rng.gen_range(1024, u16::MAX); + + let image = image::Monero::default() + .with_mapped_port(Port { + local: monerod_rpc_port, + internal: MONEROD_RPC_PORT, + }) + .with_mapped_port(Port { + local: miner_wallet_rpc_port, + internal: MINER_WALLET_RPC_PORT, + }) + .with_wallet("miner", MINER_WALLET_RPC_PORT) + .with_mapped_port(Port { + local: alice_wallet_rpc_port, + internal: ALICE_WALLET_RPC_PORT, + }) + .with_wallet("alice", ALICE_WALLET_RPC_PORT) + .with_mapped_port(Port { + local: bob_wallet_rpc_port, + internal: BOB_WALLET_RPC_PORT, + }) + .with_wallet("bob", BOB_WALLET_RPC_PORT); + + println!("running image ..."); + let docker = cli.run(image); + println!("image ran"); + + Self { + docker, + monerod_rpc_port, + miner_wallet_rpc_port, + alice_wallet_rpc_port, + bob_wallet_rpc_port, + } + } + + pub fn miner_wallet_rpc_client(&self) -> wallet::Client { + wallet::Client::localhost(self.miner_wallet_rpc_port) + } + + pub fn alice_wallet_rpc_client(&self) -> wallet::Client { + wallet::Client::localhost(self.alice_wallet_rpc_port) + } + + pub fn bob_wallet_rpc_client(&self) -> wallet::Client { + wallet::Client::localhost(self.bob_wallet_rpc_port) + } + + pub fn monerod_rpc_client(&self) -> monerod::Client { + monerod::Client::localhost(self.monerod_rpc_port) + } + + /// Initialise by creating a wallet, generating some `blocks`, and starting + /// a miner thread that mines to the primary account. Also create two + /// sub-accounts, one for Alice and one for Bob. If alice/bob_funding is + /// some, the value needs to be > 0. + pub async fn init(&self, alice_funding: u64, bob_funding: u64) -> Result<()> { + let miner_wallet = self.miner_wallet_rpc_client(); + let alice_wallet = self.alice_wallet_rpc_client(); + let bob_wallet = self.bob_wallet_rpc_client(); + let monerod = self.monerod_rpc_client(); + + miner_wallet.create_wallet("miner_wallet").await?; + alice_wallet.create_wallet("alice_wallet").await?; + bob_wallet.create_wallet("bob_wallet").await?; + + let miner = self.get_address_miner().await?.address; + let alice = self.get_address_alice().await?.address; + let bob = self.get_address_bob().await?.address; + + let _ = monerod.generate_blocks(70, &miner).await?; + self.wait_for_miner_wallet_block_height().await?; + + if alice_funding > 0 { + self.fund_account(&alice, &miner, alice_funding).await?; + self.wait_for_alice_wallet_block_height().await?; + let balance = self.get_balance_alice().await?; + debug_assert!(balance == alice_funding); + } + + if bob_funding > 0 { + self.fund_account(&bob, &miner, bob_funding).await?; + self.wait_for_bob_wallet_block_height().await?; + let balance = self.get_balance_bob().await?; + debug_assert!(balance == bob_funding); + } + + let _ = tokio::spawn(mine(monerod.clone(), miner)); + + Ok(()) + } + + /// Just create a wallet and start mining (you probably want `init()`). + pub async fn init_just_miner(&self, blocks: u32) -> Result<()> { + let wallet = self.miner_wallet_rpc_client(); + let monerod = self.monerod_rpc_client(); + + wallet.create_wallet("miner_wallet").await?; + let miner = self.get_address_miner().await?.address; + + let _ = monerod.generate_blocks(blocks, &miner).await?; + + let _ = tokio::spawn(mine(monerod.clone(), miner)); + + Ok(()) + } + + async fn fund_account(&self, address: &str, miner: &str, funding: u64) -> Result<()> { + let monerod = self.monerod_rpc_client(); + + self.transfer_from_primary(funding, address).await?; + let _ = monerod.generate_blocks(10, miner).await?; + self.wait_for_miner_wallet_block_height().await?; + + Ok(()) + } + + async fn wait_for_miner_wallet_block_height(&self) -> Result<()> { + self.wait_for_wallet_height(self.miner_wallet_rpc_client()) + .await + } + + pub async fn wait_for_alice_wallet_block_height(&self) -> Result<()> { + self.wait_for_wallet_height(self.alice_wallet_rpc_client()) + .await + } + + pub async fn wait_for_bob_wallet_block_height(&self) -> Result<()> { + self.wait_for_wallet_height(self.bob_wallet_rpc_client()) + .await + } + + // It takes a little while for the wallet to sync with monerod. + async fn wait_for_wallet_height(&self, wallet: wallet::Client) -> Result<()> { + let monerod = self.monerod_rpc_client(); + let height = monerod.get_block_count().await?; + + while wallet.block_height().await?.height < height { + time::delay_for(Duration::from_millis(WAIT_WALLET_SYNC_MILLIS)).await; + } + Ok(()) + } + + /// Get addresses for the primary account. + pub async fn get_address_miner(&self) -> Result { + let wallet = self.miner_wallet_rpc_client(); + wallet.get_address(ACCOUNT_INDEX_PRIMARY).await + } + + /// Get addresses for the Alice's account. + pub async fn get_address_alice(&self) -> Result { + let wallet = self.alice_wallet_rpc_client(); + wallet.get_address(ACCOUNT_INDEX_PRIMARY).await + } + + /// Get addresses for the Bob's account. + pub async fn get_address_bob(&self) -> Result { + let wallet = self.bob_wallet_rpc_client(); + wallet.get_address(ACCOUNT_INDEX_PRIMARY).await + } + + /// Gets the balance of the wallet primary account. + pub async fn get_balance_primary(&self) -> Result { + let wallet = self.miner_wallet_rpc_client(); + wallet.get_balance(ACCOUNT_INDEX_PRIMARY).await + } + + /// Gets the balance of Alice's account. + pub async fn get_balance_alice(&self) -> Result { + let wallet = self.alice_wallet_rpc_client(); + wallet.get_balance(ACCOUNT_INDEX_PRIMARY).await + } + + /// Gets the balance of Bob's account. + pub async fn get_balance_bob(&self) -> Result { + let wallet = self.bob_wallet_rpc_client(); + wallet.get_balance(ACCOUNT_INDEX_PRIMARY).await + } + + /// Transfers moneroj from the primary account. + pub async fn transfer_from_primary(&self, amount: u64, address: &str) -> Result { + let wallet = self.miner_wallet_rpc_client(); + wallet + .transfer(ACCOUNT_INDEX_PRIMARY, amount, address) + .await + } + + /// Transfers moneroj from Alice's account. + pub async fn transfer_from_alice(&self, amount: u64, address: &str) -> Result { + let wallet = self.alice_wallet_rpc_client(); + wallet + .transfer(ACCOUNT_INDEX_PRIMARY, amount, address) + .await + } + + /// Transfers moneroj from Bob's account. + pub async fn transfer_from_bob(&self, amount: u64, address: &str) -> Result { + let wallet = self.bob_wallet_rpc_client(); + wallet + .transfer(ACCOUNT_INDEX_PRIMARY, amount, address) + .await + } +} + +/// Mine a block ever BLOCK_TIME_SECS seconds. +async fn mine(monerod: monerod::Client, reward_address: String) -> Result<()> { + loop { + time::delay_for(Duration::from_secs(BLOCK_TIME_SECS)).await; + monerod.generate_blocks(1, &reward_address).await?; + } +} + +// We should be able to use monero-rs for this but it does not include all +// the fields. +#[derive(Clone, Debug, Deserialize)] +pub struct BlockHeader { + pub block_size: u32, + pub depth: u32, + pub difficulty: u32, + pub hash: String, + pub height: u32, + pub major_version: u32, + pub minor_version: u32, + pub nonce: u32, + pub num_txes: u32, + pub orphan_status: bool, + pub prev_hash: String, + pub reward: u64, + pub timestamp: u32, +} diff --git a/monero-harness/src/rpc.rs b/monero-harness/src/rpc.rs new file mode 100644 index 00000000..eae827c1 --- /dev/null +++ b/monero-harness/src/rpc.rs @@ -0,0 +1,63 @@ +//! JSON RPC clients for `monerd` and `monero-wallet-rpc`. +pub mod monerod; +pub mod wallet; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Debug, Clone)] +pub struct Request { + /// JSON RPC version, we hard cod this to 2.0. + jsonrpc: String, + /// Client controlled identifier, we hard code this to 1. + id: String, + /// The method to call. + method: String, + /// The method parameters. + params: T, +} + +/// JSON RPC request. +impl Request { + pub fn new(method: &str, params: T) -> Self { + Self { + jsonrpc: "2.0".to_owned(), + id: "1".to_owned(), + method: method.to_owned(), + params, + } + } +} + +/// JSON RPC response. +#[derive(Deserialize, Serialize, Debug, Clone)] +struct Response { + pub id: String, + pub jsonrpc: String, + pub result: T, +} + +#[cfg(test)] +mod tests { + use super::*; + use spectral::prelude::*; + + #[derive(Serialize, Debug, Clone)] + struct Params { + val: u32, + } + + #[test] + fn can_serialize_request_with_params() { + // Dummy method and parameters. + let params = Params { val: 0 }; + let method = "get_block"; + + let r = Request::new(method, ¶ms); + let got = serde_json::to_string(&r).expect("failed to serialize request"); + + let want = + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"get_block\",\"params\":{\"val\":0}}" + .to_string(); + assert_that!(got).is_equal_to(want); + } +} diff --git a/monero-harness/src/rpc/monerod.rs b/monero-harness/src/rpc/monerod.rs new file mode 100644 index 00000000..9e32c645 --- /dev/null +++ b/monero-harness/src/rpc/monerod.rs @@ -0,0 +1,132 @@ +use crate::{ + rpc::{Request, Response}, + BlockHeader, +}; + +use anyhow::Result; +use reqwest::Url; +use serde::{Deserialize, Serialize}; + +// #[cfg(not(test))] +// use tracing::debug; +// +// #[cfg(test)] +use std::eprintln as debug; + +/// RPC client for monerod and monero-wallet-rpc. +#[derive(Debug, Clone)] +pub struct Client { + pub inner: reqwest::Client, + pub url: Url, +} + +impl Client { + /// New local host monerod RPC client. + pub fn localhost(port: u16) -> Self { + let url = format!("http://127.0.0.1:{}/json_rpc", port); + let url = Url::parse(&url).expect("url is well formed"); + + Self { + inner: reqwest::Client::new(), + url, + } + } + + pub async fn generate_blocks( + &self, + amount_of_blocks: u32, + wallet_address: &str, + ) -> Result { + let params = GenerateBlocksParams { + amount_of_blocks, + wallet_address: wallet_address.to_owned(), + }; + let request = Request::new("generateblocks", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("generate blocks response: {}", response); + + let res: Response = serde_json::from_str(&response)?; + + Ok(res.result) + } + + // $ curl http://127.0.0.1:18081/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"get_block_header_by_height","params":{"height":1}}' -H 'Content-Type: application/json' + pub async fn get_block_header_by_height(&self, height: u32) -> Result { + let params = GetBlockHeaderByHeightParams { height }; + let request = Request::new("get_block_header_by_height", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("get block header by height response: {}", response); + + let res: Response = serde_json::from_str(&response)?; + + Ok(res.result.block_header) + } + + pub async fn get_block_count(&self) -> Result { + let request = Request::new("get_block_count", ""); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("get block count response: {}", response); + + let res: Response = serde_json::from_str(&response)?; + + Ok(res.result.count) + } +} + +#[derive(Clone, Debug, Serialize)] +struct GenerateBlocksParams { + amount_of_blocks: u32, + wallet_address: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GenerateBlocks { + pub blocks: Vec, + pub height: u32, + pub status: String, +} + +#[derive(Clone, Debug, Serialize)] +struct GetBlockHeaderByHeightParams { + height: u32, +} + +#[derive(Clone, Debug, Deserialize)] +struct GetBlockHeaderByHeight { + block_header: BlockHeader, + status: String, + untrusted: bool, +} + +#[derive(Clone, Debug, Deserialize)] +struct BlockCount { + count: u32, + status: String, +} diff --git a/monero-harness/src/rpc/wallet.rs b/monero-harness/src/rpc/wallet.rs new file mode 100644 index 00000000..7afaa2c8 --- /dev/null +++ b/monero-harness/src/rpc/wallet.rs @@ -0,0 +1,397 @@ +use crate::rpc::{Request, Response}; + +use anyhow::Result; +use reqwest::Url; +use serde::{Deserialize, Serialize}; + +// TODO: Either use println! directly or import tracing also? +use std::println as debug; + +/// JSON RPC client for monero-wallet-rpc. +#[derive(Debug)] +pub struct Client { + pub inner: reqwest::Client, + pub url: Url, +} + +impl Client { + /// Constructs a monero-wallet-rpc client with localhost endpoint. + pub fn localhost(port: u16) -> Self { + let url = format!("http://127.0.0.1:{}/json_rpc", port); + let url = Url::parse(&url).expect("url is well formed"); + + Client::new(url) + } + + /// Constructs a monero-wallet-rpc client with `url` endpoint. + pub fn new(url: Url) -> Self { + Self { + inner: reqwest::Client::new(), + url, + } + } + + /// Get addresses for account by index. + pub async fn get_address(&self, account_index: u32) -> Result { + let params = GetAddressParams { account_index }; + let request = Request::new("get_address", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("get address RPC response: {}", response); + + let r: Response = serde_json::from_str(&response)?; + Ok(r.result) + } + + /// Gets the balance of account by index. + pub async fn get_balance(&self, index: u32) -> Result { + let params = GetBalanceParams { + account_index: index, + }; + let request = Request::new("get_balance", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!( + "get balance of account index {} RPC response: {}", + index, response + ); + + let res: Response = serde_json::from_str(&response)?; + + let balance = res.result.balance; + + Ok(balance) + } + + pub async fn create_account(&self, label: &str) -> Result { + let params = LabelParams { + label: label.to_owned(), + }; + let request = Request::new("create_account", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("create account RPC response: {}", response); + + let r: Response = serde_json::from_str(&response)?; + Ok(r.result) + } + + /// Get accounts, filtered by tag ("" for no filtering). + pub async fn get_accounts(&self, tag: &str) -> Result { + let params = TagParams { + tag: tag.to_owned(), + }; + let request = Request::new("get_accounts", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("get accounts RPC response: {}", response); + + let r: Response = serde_json::from_str(&response)?; + + Ok(r.result) + } + + /// Creates a wallet using `filename`. + pub async fn create_wallet(&self, filename: &str) -> Result<()> { + let params = CreateWalletParams { + filename: filename.to_owned(), + language: "English".to_owned(), + }; + let request = Request::new("create_wallet", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("create wallet RPC response: {}", response); + + Ok(()) + } + + /// Transfers `amount` moneroj from `account_index` to `address`. + pub async fn transfer( + &self, + account_index: u32, + amount: u64, + address: &str, + ) -> Result { + let dest = vec![Destination { + amount, + address: address.to_owned(), + }]; + self.multi_transfer(account_index, dest).await + } + + /// Transfers moneroj from `account_index` to `destinations`. + pub async fn multi_transfer( + &self, + account_index: u32, + destinations: Vec, + ) -> Result { + let params = TransferParams { + account_index, + destinations, + get_tx_key: true, + }; + let request = Request::new("transfer", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("transfer RPC response: {}", response); + + let r: Response = serde_json::from_str(&response)?; + Ok(r.result) + } + + /// Get wallet block height, this might be behind monerod height. + pub(crate) async fn block_height(&self) -> Result { + let request = Request::new("get_height", ""); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("wallet height RPC response: {}", response); + + let r: Response = serde_json::from_str(&response)?; + Ok(r.result) + } + + /// Check a transaction in the blockchain with its secret key. + pub async fn check_tx_key( + &self, + tx_id: &str, + tx_key: &str, + address: &str, + ) -> Result { + let params = CheckTxKeyParams { + tx_id: tx_id.to_owned(), + tx_key: tx_key.to_owned(), + address: address.to_owned(), + }; + let request = Request::new("check_tx_key", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("transfer RPC response: {}", response); + + let r: Response = serde_json::from_str(&response)?; + Ok(r.result) + } + + pub async fn generate_from_keys( + &self, + address: &str, + spend_key: &str, + view_key: &str, + ) -> Result { + let params = GenerateFromKeysParams { + restore_height: 0, + filename: view_key.into(), + address: address.into(), + spendkey: spend_key.into(), + viewkey: view_key.into(), + password: "".into(), + autosave_current: true, + }; + let request = Request::new("generate_from_keys", params); + + let response = self + .inner + .post(self.url.clone()) + .json(&request) + .send() + .await? + .text() + .await?; + + debug!("generate_from_keys RPC response: {}", response); + + let r: Response = serde_json::from_str(&response)?; + Ok(r.result) + } +} + +#[derive(Serialize, Debug, Clone)] +struct GetAddressParams { + account_index: u32, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct GetAddress { + pub address: String, +} + +#[derive(Serialize, Debug, Clone)] +struct GetBalanceParams { + account_index: u32, +} + +#[derive(Deserialize, Debug, Clone)] +struct GetBalance { + balance: u64, + blocks_to_unlock: u32, + multisig_import_needed: bool, + time_to_unlock: u32, + unlocked_balance: u64, +} + +#[derive(Serialize, Debug, Clone)] +struct LabelParams { + label: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct CreateAccount { + pub account_index: u32, + pub address: String, +} + +#[derive(Serialize, Debug, Clone)] +struct TagParams { + tag: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct GetAccounts { + pub subaddress_accounts: Vec, + pub total_balance: u64, + pub total_unlocked_balance: u64, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SubAddressAccount { + pub account_index: u32, + pub balance: u32, + pub base_address: String, + pub label: String, + pub tag: String, + pub unlocked_balance: u64, +} + +#[derive(Serialize, Debug, Clone)] +struct CreateWalletParams { + filename: String, + language: String, +} + +#[derive(Serialize, Debug, Clone)] +struct TransferParams { + // Transfer from this account. + account_index: u32, + // Destinations to receive XMR: + destinations: Vec, + // Return the transaction key after sending. + get_tx_key: bool, +} + +#[derive(Serialize, Debug, Clone)] +pub struct Destination { + amount: u64, + address: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Transfer { + pub amount: u64, + pub fee: u64, + pub multisig_txset: String, + pub tx_blob: String, + pub tx_hash: String, + pub tx_key: String, + pub tx_metadata: String, + pub unsigned_txset: String, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct BlockHeight { + pub height: u32, +} + +#[derive(Serialize, Debug, Clone)] +struct CheckTxKeyParams { + #[serde(rename = "txid")] + tx_id: String, + tx_key: String, + address: String, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct CheckTxKey { + pub confirmations: u32, + pub in_pool: bool, + pub received: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GenerateFromKeysParams { + pub restore_height: u32, + pub filename: String, + pub address: String, + pub spendkey: String, + pub viewkey: String, + pub password: String, + pub autosave_current: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GenerateFromKeys { + pub address: String, + pub info: String, +} diff --git a/monero-harness/tests/client.rs b/monero-harness/tests/client.rs new file mode 100644 index 00000000..102b97b3 --- /dev/null +++ b/monero-harness/tests/client.rs @@ -0,0 +1,36 @@ +use monero_harness::Monero; +use spectral::prelude::*; +use testcontainers::clients::Cli; + +const ALICE_FUND_AMOUNT: u64 = 1_000_000_000_000; +const BOB_FUND_AMOUNT: u64 = 0; + +fn init_cli() -> Cli { + Cli::default() +} + +async fn init_monero(tc: &'_ Cli) -> Monero<'_> { + let monero = Monero::new(tc); + let _ = monero.init(ALICE_FUND_AMOUNT, BOB_FUND_AMOUNT).await; + + monero +} + +#[tokio::test] +async fn init_accounts_for_alice_and_bob() { + let cli = init_cli(); + let monero = init_monero(&cli).await; + + let got_balance_alice = monero + .get_balance_alice() + .await + .expect("failed to get alice's balance"); + + let got_balance_bob = monero + .get_balance_bob() + .await + .expect("failed to get bob's balance"); + + assert_that!(got_balance_alice).is_equal_to(ALICE_FUND_AMOUNT); + assert_that!(got_balance_bob).is_equal_to(BOB_FUND_AMOUNT); +} diff --git a/monero-harness/tests/monerod.rs b/monero-harness/tests/monerod.rs new file mode 100644 index 00000000..52270db6 --- /dev/null +++ b/monero-harness/tests/monerod.rs @@ -0,0 +1,47 @@ +use monero_harness::{rpc::monerod::Client, Monero}; +use spectral::prelude::*; +use std::time::Duration; +use testcontainers::clients::Cli; +use tokio::time; + +fn init_cli() -> Cli { + Cli::default() +} + +#[tokio::test] +async fn connect_to_monerod() { + let tc = init_cli(); + let monero = Monero::new(&tc); + let cli = Client::localhost(monero.monerod_rpc_port); + + let header = cli + .get_block_header_by_height(0) + .await + .expect("failed to get block 0"); + + assert_that!(header.height).is_equal_to(0); +} + +#[tokio::test] +async fn miner_is_running_and_producing_blocks() { + let tc = init_cli(); + let monero = Monero::new(&tc); + let cli = Client::localhost(monero.monerod_rpc_port); + + monero + .init_just_miner(2) + .await + .expect("Failed to initialize"); + + // Only need 3 seconds since we mine a block every second but + // give it 5 just for good measure. + time::delay_for(Duration::from_secs(5)).await; + + // We should have at least 5 blocks by now. + let header = cli + .get_block_header_by_height(5) + .await + .expect("failed to get block"); + + assert_that!(header.height).is_equal_to(5); +} diff --git a/monero-harness/tests/wallet.rs b/monero-harness/tests/wallet.rs new file mode 100644 index 00000000..91ad7d06 --- /dev/null +++ b/monero-harness/tests/wallet.rs @@ -0,0 +1,89 @@ +use monero_harness::{rpc::wallet::Client, Monero}; +use spectral::prelude::*; +use testcontainers::clients::Cli; + +#[tokio::test] +async fn wallet_and_accounts() { + let tc = Cli::default(); + let monero = Monero::new(&tc); + let miner_wallet = Client::localhost(monero.miner_wallet_rpc_port); + + println!("creating wallet ..."); + + let _ = miner_wallet + .create_wallet("wallet") + .await + .expect("failed to create wallet"); + + let got = miner_wallet + .get_balance(0) + .await + .expect("failed to get balance"); + let want = 0; + + assert_that!(got).is_equal_to(want); +} + +#[tokio::test] +async fn create_account_and_retrieve_it() { + let tc = Cli::default(); + let monero = Monero::new(&tc); + let cli = Client::localhost(monero.miner_wallet_rpc_port); + + let label = "Iron Man"; // This is intentionally _not_ Alice or Bob. + + let _ = cli + .create_wallet("wallet") + .await + .expect("failed to create wallet"); + + let _ = cli + .create_account(label) + .await + .expect("failed to create account"); + + let mut found: bool = false; + let accounts = cli + .get_accounts("") // Empty filter. + .await + .expect("failed to get accounts"); + for account in accounts.subaddress_accounts { + if account.label == label { + found = true; + } + } + assert!(found); +} + +#[tokio::test] +async fn transfer_and_check_tx_key() { + let fund_alice = 1_000_000_000_000; + let fund_bob = 0; + + let tc = Cli::default(); + let monero = Monero::new(&tc); + let _ = monero.init(fund_alice, fund_bob).await; + + let address_bob = monero + .get_address_bob() + .await + .expect("failed to get Bob's address") + .address; + + let transfer_amount = 100; + let transfer = monero + .transfer_from_alice(transfer_amount, &address_bob) + .await + .expect("transfer failed"); + + let tx_id = transfer.tx_hash; + let tx_key = transfer.tx_key; + + let cli = monero.miner_wallet_rpc_client(); + let res = cli + .check_tx_key(&tx_id, &tx_key, &address_bob) + .await + .expect("failed to check tx by key"); + + assert_that!(res.received).is_equal_to(transfer_amount); +} diff --git a/rust-toolchain b/rust-toolchain new file mode 100644 index 00000000..a66b2431 --- /dev/null +++ b/rust-toolchain @@ -0,0 +1 @@ +nightly-2020-08-13 diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..100f5106 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,9 @@ +edition = "2018" +condense_wildcard_suffixes = true +format_macro_matchers = true +merge_imports = true +use_field_init_shorthand = true +format_code_in_doc_comments = true +normalize_comments = true +wrap_comments = true +overflow_delimited_expr = true diff --git a/xmr-btc/Cargo.toml b/xmr-btc/Cargo.toml new file mode 100644 index 00000000..c49df15d --- /dev/null +++ b/xmr-btc/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "xmr-btc" +version = "0.1.0" +authors = ["CoBloX Team "] +edition = "2018" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +bitcoin = { version = "0.23", features = ["rand"] } +cross-curve-dleq = { git = "https://github.com/comit-network/cross-curve-dleq", rev = "a3e57a70d332b4ce9600663453b9bd02936d76bf" } +curve25519-dalek = "2" +ecdsa_fun = { version = "0.3.1", features = ["libsecp_compat"] } +ed25519-dalek = "1.0.0-pre.4" # Cannot be 1 because they depend on curve25519-dalek version 3 +miniscript = "1" +monero = "0.9" +rand = "0.7" +sha2 = "0.9" +thiserror = "1" + +[dev-dependencies] +base64 = "0.12" +bitcoin-harness = { git = "https://github.com/coblox/bitcoin-harness-rs", rev = "d402b36d3d6406150e3bfb71492ff4a0a7cb290e" } +monero-harness = { path = "../monero-harness" } +reqwest = { version = "0.10", default-features = false } +testcontainers = "0.10" +tokio = { version = "0.2", default-features = false, features = ["blocking", "macros", "rt-core", "time", "rt-threaded"] } diff --git a/xmr-btc/src/alice.rs b/xmr-btc/src/alice.rs new file mode 100644 index 00000000..2e5c1d4f --- /dev/null +++ b/xmr-btc/src/alice.rs @@ -0,0 +1,516 @@ +use anyhow::{anyhow, Result}; +use ecdsa_fun::adaptor::{Adaptor, EncryptedSignature}; +use rand::{CryptoRng, RngCore}; + +use crate::{bitcoin, bitcoin::GetRawTransaction, bob, monero, monero::ImportOutput}; +use ecdsa_fun::{nonce::Deterministic, Signature}; +use sha2::Sha256; + +#[derive(Debug)] +pub struct Message0 { + pub(crate) A: bitcoin::PublicKey, + pub(crate) S_a_monero: monero::PublicKey, + pub(crate) S_a_bitcoin: bitcoin::PublicKey, + pub(crate) dleq_proof_s_a: cross_curve_dleq::Proof, + pub(crate) v_a: monero::PrivateViewKey, + pub(crate) redeem_address: bitcoin::Address, + pub(crate) punish_address: bitcoin::Address, +} + +#[derive(Debug)] +pub struct Message1 { + pub(crate) tx_cancel_sig: Signature, + pub(crate) tx_refund_encsig: EncryptedSignature, +} + +#[derive(Debug)] +pub struct Message2 { + pub(crate) tx_lock_proof: monero::TransferProof, +} + +#[derive(Debug)] +pub struct State0 { + a: bitcoin::SecretKey, + s_a: cross_curve_dleq::Scalar, + v_a: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, +} + +impl State0 { + pub fn new( + rng: &mut R, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + ) -> Self { + let a = bitcoin::SecretKey::new_random(rng); + + let s_a = cross_curve_dleq::Scalar::random(rng); + let v_a = monero::PrivateViewKey::new_random(rng); + + Self { + a, + s_a, + v_a, + redeem_address, + punish_address, + btc, + xmr, + refund_timelock, + punish_timelock, + } + } + + pub fn next_message(&self, rng: &mut R) -> Message0 { + let dleq_proof_s_a = cross_curve_dleq::Proof::new(rng, &self.s_a); + + Message0 { + A: self.a.public(), + S_a_monero: monero::PublicKey::from_private_key(&monero::PrivateKey { + scalar: self.s_a.into_ed25519(), + }), + S_a_bitcoin: self.s_a.into_secp256k1().into(), + dleq_proof_s_a, + v_a: self.v_a, + redeem_address: self.redeem_address.clone(), + punish_address: self.punish_address.clone(), + } + } + + pub fn receive(self, msg: bob::Message0) -> Result { + msg.dleq_proof_s_b.verify( + &msg.S_b_bitcoin.clone().into(), + msg.S_b_monero + .point + .decompress() + .ok_or_else(|| anyhow!("S_b is not a monero curve point"))?, + )?; + + let v = self.v_a + msg.v_b; + + Ok(State1 { + a: self.a, + B: msg.B, + s_a: self.s_a, + S_b_monero: msg.S_b_monero, + S_b_bitcoin: msg.S_b_bitcoin, + v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: msg.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + }) + } +} + +#[derive(Debug)] +pub struct State1 { + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + s_a: cross_curve_dleq::Scalar, + S_b_monero: monero::PublicKey, + S_b_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, +} + +impl State1 { + pub fn receive(self, msg: bob::Message1) -> State2 { + State2 { + a: self.a, + B: self.B, + s_a: self.s_a, + S_b_monero: self.S_b_monero, + S_b_bitcoin: self.S_b_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: msg.tx_lock, + } + } +} + +#[derive(Debug)] +pub struct State2 { + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + s_a: cross_curve_dleq::Scalar, + S_b_monero: monero::PublicKey, + S_b_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, +} + +impl State2 { + pub fn next_message(&self) -> Message1 { + let tx_cancel = bitcoin::TxCancel::new( + &self.tx_lock, + self.refund_timelock, + self.a.public(), + self.B.clone(), + ); + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &self.refund_address); + // 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. + // recover(encsign(a, S_b, d), sign(a, d), S_b) = s_b where d is a digest, (a, + // A) is alice's keypair and (s_b, S_b) is bob's keypair. + let tx_refund_encsig = self.a.encsign(self.S_b_bitcoin.clone(), tx_refund.digest()); + + let tx_cancel_sig = self.a.sign(tx_cancel.digest()); + Message1 { + tx_refund_encsig, + tx_cancel_sig, + } + } + + pub fn receive(self, msg: bob::Message2) -> Result { + let tx_cancel = bitcoin::TxCancel::new( + &self.tx_lock, + self.refund_timelock, + self.a.public(), + self.B.clone(), + ); + bitcoin::verify_sig(&self.B, &tx_cancel.digest(), &msg.tx_cancel_sig)?; + let tx_punish = + bitcoin::TxPunish::new(&tx_cancel, &self.punish_address, self.punish_timelock); + bitcoin::verify_sig(&self.B, &tx_punish.digest(), &msg.tx_punish_sig)?; + + Ok(State3 { + a: self.a, + B: self.B, + s_a: self.s_a, + S_b_monero: self.S_b_monero, + S_b_bitcoin: self.S_b_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_punish_sig_bob: msg.tx_punish_sig, + tx_cancel_sig_bob: msg.tx_cancel_sig, + }) + } +} + +#[derive(Debug)] +pub struct State3 { + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + s_a: cross_curve_dleq::Scalar, + S_b_monero: monero::PublicKey, + S_b_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_punish_sig_bob: bitcoin::Signature, + tx_cancel_sig_bob: bitcoin::Signature, +} + +impl State3 { + pub async fn watch_for_lock_btc(self, bitcoin_wallet: &W) -> Result + where + W: bitcoin::GetRawTransaction, + { + let _ = bitcoin_wallet + .get_raw_transaction(self.tx_lock.txid()) + .await?; + + Ok(State4 { + a: self.a, + B: self.B, + s_a: self.s_a, + S_b_monero: self.S_b_monero, + S_b_bitcoin: self.S_b_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_punish_sig_bob: self.tx_punish_sig_bob, + tx_cancel_sig_bob: self.tx_cancel_sig_bob, + }) + } +} + +#[derive(Debug)] +pub struct State4 { + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + s_a: cross_curve_dleq::Scalar, + S_b_monero: monero::PublicKey, + S_b_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_punish_sig_bob: bitcoin::Signature, + tx_cancel_sig_bob: bitcoin::Signature, +} + +impl State4 { + pub async fn lock_xmr(self, monero_wallet: &W) -> Result<(State4b, monero::Amount)> + where + W: monero::Transfer, + { + let S_a = monero::PublicKey::from_private_key(&monero::PrivateKey { + scalar: self.s_a.into_ed25519(), + }); + let S_b = self.S_b_monero; + + let (tx_lock_proof, fee) = monero_wallet + .transfer(S_a + S_b, self.v.public(), self.xmr) + .await?; + + Ok(( + State4b { + a: self.a, + B: self.B, + s_a: self.s_a, + S_b_monero: self.S_b_monero, + S_b_bitcoin: self.S_b_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_lock_proof, + tx_punish_sig_bob: self.tx_punish_sig_bob, + tx_cancel_sig_bob: self.tx_cancel_sig_bob, + }, + fee, + )) + } + + pub async fn punish( + &self, + bitcoin_wallet: &W, + ) -> Result<()> { + let tx_cancel = bitcoin::TxCancel::new( + &self.tx_lock, + self.refund_timelock, + self.a.public(), + self.B.clone(), + ); + let tx_punish = + bitcoin::TxPunish::new(&tx_cancel, &self.punish_address, self.punish_timelock); + + { + let sig_a = self.a.sign(tx_cancel.digest()); + let sig_b = self.tx_cancel_sig_bob.clone(); + + let signed_tx_cancel = tx_cancel.clone().add_signatures( + &self.tx_lock, + (self.a.public(), sig_a), + (self.B.clone(), sig_b), + )?; + + let _ = bitcoin_wallet + .broadcast_signed_transaction(signed_tx_cancel) + .await?; + } + + { + let sig_a = self.a.sign(tx_punish.digest()); + let sig_b = self.tx_punish_sig_bob.clone(); + + let signed_tx_punish = tx_punish.add_signatures( + &tx_cancel, + (self.a.public(), sig_a), + (self.B.clone(), sig_b), + )?; + + let _ = bitcoin_wallet + .broadcast_signed_transaction(signed_tx_punish) + .await?; + } + + Ok(()) + } +} + +#[derive(Debug)] +pub struct State4b { + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + s_a: cross_curve_dleq::Scalar, + S_b_monero: monero::PublicKey, + S_b_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_lock_proof: monero::TransferProof, + tx_punish_sig_bob: bitcoin::Signature, + tx_cancel_sig_bob: bitcoin::Signature, +} + +impl State4b { + pub fn next_message(&self) -> Message2 { + Message2 { + tx_lock_proof: self.tx_lock_proof.clone(), + } + } + + pub fn receive(self, msg: bob::Message3) -> State5 { + State5 { + a: self.a, + B: self.B, + s_a: self.s_a, + S_b_monero: self.S_b_monero, + S_b_bitcoin: self.S_b_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_punish_sig_bob: self.tx_punish_sig_bob, + tx_redeem_encsig: msg.tx_redeem_encsig, + } + } + + // watch for refund on btc, recover s_b and refund xmr + pub async fn refund_xmr(self, bitcoin_wallet: &B, monero_wallet: &M) -> Result<()> + where + B: GetRawTransaction, + M: ImportOutput, + { + let tx_cancel = bitcoin::TxCancel::new( + &self.tx_lock, + self.refund_timelock, + self.a.public(), + self.B.clone(), + ); + + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &self.refund_address); + + let tx_refund_encsig = self.a.encsign(self.S_b_bitcoin.clone(), tx_refund.digest()); + + let tx_refund_candidate = bitcoin_wallet.get_raw_transaction(tx_refund.txid()).await?; + + let tx_refund_sig = + tx_refund.extract_signature_by_key(tx_refund_candidate, self.a.public())?; + + let s_b = bitcoin::recover(self.S_b_bitcoin, tx_refund_sig, tx_refund_encsig)?; + let s_b = + monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order(s_b.to_bytes())); + + let s = s_b.scalar + self.s_a.into_ed25519(); + + // NOTE: This actually generates and opens a new wallet, closing the currently + // open one. + monero_wallet + .import_output(monero::PrivateKey::from_scalar(s), self.v) + .await?; + + Ok(()) + } +} + +#[derive(Debug)] +pub struct State5 { + a: bitcoin::SecretKey, + B: bitcoin::PublicKey, + s_a: cross_curve_dleq::Scalar, + S_b_monero: monero::PublicKey, + S_b_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_punish_sig_bob: bitcoin::Signature, + tx_redeem_encsig: EncryptedSignature, +} + +impl State5 { + pub async fn redeem_btc( + &self, + bitcoin_wallet: &W, + ) -> Result<()> { + let adaptor = Adaptor::>::default(); + + let tx_redeem = bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address); + + let sig_a = self.a.sign(tx_redeem.digest()); + let sig_b = + adaptor.decrypt_signature(&self.s_a.into_secp256k1(), self.tx_redeem_encsig.clone()); + + let sig_tx_redeem = tx_redeem.add_signatures( + &self.tx_lock, + (self.a.public(), sig_a), + (self.B.clone(), sig_b), + )?; + bitcoin_wallet + .broadcast_signed_transaction(sig_tx_redeem) + .await?; + + Ok(()) + } +} diff --git a/xmr-btc/src/bitcoin.rs b/xmr-btc/src/bitcoin.rs new file mode 100644 index 00000000..25a1b458 --- /dev/null +++ b/xmr-btc/src/bitcoin.rs @@ -0,0 +1,209 @@ +pub mod transactions; +#[cfg(test)] +pub mod wallet; + +use anyhow::{anyhow, bail, Result}; +use async_trait::async_trait; +use bitcoin::{ + hashes::{hex::ToHex, Hash}, + secp256k1, + util::psbt::PartiallySignedTransaction, + SigHash, Transaction, +}; +use ecdsa_fun::{ + adaptor::Adaptor, + fun::{ + marker::{Jacobian, Mark}, + Point, Scalar, + }, + nonce::Deterministic, + ECDSA, +}; +use miniscript::{Descriptor, Segwitv0}; +use rand::{CryptoRng, RngCore}; +use sha2::Sha256; +use std::str::FromStr; + +pub use crate::bitcoin::transactions::{TxCancel, TxLock, TxPunish, TxRedeem, TxRefund}; +pub use bitcoin::{Address, Amount, OutPoint, Txid}; +pub use ecdsa_fun::{adaptor::EncryptedSignature, Signature}; + +#[cfg(test)] +pub use wallet::{make_wallet, Wallet}; + +pub const TX_FEE: u64 = 10_000; + +#[derive(Debug, Clone)] +pub struct SecretKey { + inner: Scalar, + public: Point, +} + +impl SecretKey { + pub fn new_random(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.clone()) + } + + pub fn to_bytes(&self) -> [u8; 32] { + self.inner.to_bytes() + } + + pub fn sign(&self, digest: SigHash) -> Signature { + let ecdsa = ECDSA::>::default(); + + ecdsa.sign(&self.inner, &digest.into_inner()) + } + + // 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 for tx_refund using b + // alice sends over an encrypted signature on A for tx_refund using a encrypted + // with S_b we want to leak s_b + + // produced (by Alice) encsig - published (by Bob) sig = s_b (it's not really + // subtraction, it's recover) + + // self = a, Y = S_b, digest = tx_refund + pub fn encsign(&self, Y: PublicKey, digest: SigHash) -> EncryptedSignature { + let adaptor = Adaptor::>::default(); + + adaptor.encrypted_sign(&self.inner, &Y.0, &digest.into_inner()) + } +} + +#[derive(Debug, Clone)] +pub struct PublicKey(Point); + +impl From for Point { + fn from(from: PublicKey) -> Self { + from.0.mark::() + } +} + +impl From for SecretKey { + fn from(scalar: Scalar) -> Self { + let ecdsa = ECDSA::<()>::default(); + let public = ecdsa.verification_key_for(&scalar); + + Self { + inner: scalar, + public, + } + } +} + +impl From 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.into_inner(), &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::>::default(); + + if adaptor.verify_encrypted_signature( + &verification_key.0, + &encryption_key.0, + &digest.into_inner(), + &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) -> Descriptor { + const MINISCRIPT_TEMPLATE: &str = "c:and_v(v:pk(A),pk_k(B))"; + + // NOTE: This shouldn't be a source of error, but maybe it is + let A = ToHex::to_hex(&secp256k1::PublicKey::from(A)); + let B = ToHex::to_hex(&secp256k1::PublicKey::from(B)); + + let miniscript = MINISCRIPT_TEMPLATE.replace("A", &A).replace("B", &B); + + let miniscript = miniscript::Miniscript::::from_str(&miniscript) + .expect("a valid miniscript"); + + Descriptor::Wsh(miniscript) +} + +#[async_trait] +pub trait BuildTxLockPsbt { + async fn build_tx_lock_psbt( + &self, + output_address: Address, + output_amount: Amount, + ) -> Result; +} + +#[async_trait] +pub trait SignTxLock { + async fn sign_tx_lock(&self, tx_lock: TxLock) -> Result; +} + +#[async_trait] +pub trait BroadcastSignedTransaction { + async fn broadcast_signed_transaction(&self, transaction: Transaction) -> Result; +} + +#[async_trait] +pub trait GetRawTransaction { + async fn get_raw_transaction(&self, txid: Txid) -> Result; +} + +pub fn recover(S: PublicKey, sig: Signature, encsig: EncryptedSignature) -> Result { + let adaptor = Adaptor::>::default(); + + let s = adaptor + .recover_decryption_key(&S.0, &sig, &encsig) + .map(SecretKey::from) + .ok_or_else(|| anyhow!("secret recovery failure"))?; + + Ok(s) +} diff --git a/xmr-btc/src/bitcoin/transactions.rs b/xmr-btc/src/bitcoin/transactions.rs new file mode 100644 index 00000000..9eb7878d --- /dev/null +++ b/xmr-btc/src/bitcoin/transactions.rs @@ -0,0 +1,498 @@ +use crate::bitcoin::{ + build_shared_output_descriptor, verify_sig, BuildTxLockPsbt, OutPoint, PublicKey, Txid, TX_FEE, +}; +use anyhow::{bail, Context, Result}; +use bitcoin::{ + util::{bip143::SighashComponents, psbt::PartiallySignedTransaction}, + Address, Amount, Network, SigHash, Transaction, TxIn, TxOut, +}; +use ecdsa_fun::Signature; +use miniscript::Descriptor; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct TxLock { + inner: Transaction, + output_descriptor: Descriptor<::bitcoin::PublicKey>, +} + +impl TxLock { + pub async fn new(wallet: &W, amount: Amount, A: PublicKey, B: PublicKey) -> Result + where + W: BuildTxLockPsbt, + { + let lock_output_descriptor = build_shared_output_descriptor(A.0, B.0); + let address = lock_output_descriptor + .address(Network::Regtest) + .expect("can derive address from descriptor"); + + // We construct a psbt for convenience + let psbt = wallet.build_tx_lock_psbt(address, amount).await?; + + // We don't take advantage of psbt functionality yet, instead we convert to a + // raw transaction + let inner = psbt.extract_tx(); + + Ok(Self { + inner, + output_descriptor: lock_output_descriptor, + }) + } + + pub fn lock_amount(&self) -> Amount { + Amount::from_sat(self.inner.output[self.lock_output_vout()].value) + } + + pub fn txid(&self) -> Txid { + self.inner.txid() + } + + pub fn as_outpoint(&self) -> OutPoint { + #[allow(clippy::cast_possible_truncation)] + OutPoint::new(self.inner.txid(), self.lock_output_vout() as u32) + } + + /// Retreive the index of the locked output in the transaction outputs + /// vector + fn lock_output_vout(&self) -> usize { + self.inner + .output + .iter() + .position(|output| output.script_pubkey == self.output_descriptor.script_pubkey()) + .expect("transaction contains lock output") + } + + fn build_spend_transaction( + &self, + spend_address: &Address, + sequence: Option, + ) -> (Transaction, TxIn) { + let previous_output = self.as_outpoint(); + + let tx_in = TxIn { + previous_output, + script_sig: Default::default(), + sequence: sequence.unwrap_or(0xFFFF_FFFF), + witness: Vec::new(), + }; + + let tx_out = TxOut { + value: self.inner.output[self.lock_output_vout()].value - TX_FEE, + script_pubkey: spend_address.script_pubkey(), + }; + + let transaction = Transaction { + version: 2, + lock_time: 0, + input: vec![tx_in.clone()], + output: vec![tx_out], + }; + + (transaction, tx_in) + } +} + +impl From for PartiallySignedTransaction { + fn from(from: TxLock) -> Self { + PartiallySignedTransaction::from_unsigned_tx(from.inner).expect("to be unsigned") + } +} + +#[derive(Debug, Clone)] +pub struct TxRedeem { + inner: Transaction, + digest: SigHash, +} + +impl TxRedeem { + pub fn new(tx_lock: &TxLock, redeem_address: &Address) -> Self { + // lock_input is the shared output that is now being used as an input for the + // redeem transaction + let (tx_redeem, lock_input) = tx_lock.build_spend_transaction(redeem_address, None); + + let digest = SighashComponents::new(&tx_redeem).sighash_all( + &lock_input, + &tx_lock.output_descriptor.witness_script(), + tx_lock.lock_amount().as_sat(), + ); + + Self { + inner: tx_redeem, + digest, + } + } + + pub fn txid(&self) -> Txid { + self.inner.txid() + } + + pub fn digest(&self) -> SigHash { + self.digest + } + + pub fn add_signatures( + self, + tx_lock: &TxLock, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + key: A.0.into(), + }; + let B = ::bitcoin::PublicKey { + compressed: true, + key: B.0.into(), + }; + + // The order in which these are inserted doesn't matter + satisfier.insert(A, (sig_a.into(), ::bitcoin::SigHashType::All)); + satisfier.insert(B, (sig_b.into(), ::bitcoin::SigHashType::All)); + + satisfier + }; + + let mut tx_redeem = self.inner; + tx_lock + .output_descriptor + .satisfy(&mut tx_redeem.input[0], satisfier)?; + + Ok(tx_redeem) + } + + pub fn extract_signature_by_key( + &self, + candidate_transaction: Transaction, + B: PublicKey, + ) -> Result { + let input = match candidate_transaction.input.as_slice() { + [input] => input, + [] => bail!(NoInputs), + [inputs @ ..] => bail!(TooManyInputs(inputs.len())), + }; + + let sigs = match input + .witness + .iter() + .map(|vec| vec.as_slice()) + .collect::>() + .as_slice() + { + [sig_1, sig_2, _script] => [sig_1, sig_2] + .iter() + .map(|sig| { + bitcoin::secp256k1::Signature::from_der(&sig[..sig.len() - 1]) + .map(Signature::from) + }) + .collect::, _>>(), + [] => bail!(EmptyWitnessStack), + [witnesses @ ..] => bail!(NotThreeWitnesses(witnesses.len())), + }?; + + let sig = sigs + .into_iter() + .find(|sig| verify_sig(&B, &self.digest(), &sig).is_ok()) + .context("neither signature on witness stack verifies against B")?; + + Ok(sig) + } +} + +#[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); + +#[derive(Debug, Clone)] +pub struct TxCancel { + inner: Transaction, + digest: SigHash, + output_descriptor: Descriptor<::bitcoin::PublicKey>, +} + +impl TxCancel { + pub fn new(tx_lock: &TxLock, cancel_timelock: u32, A: PublicKey, B: PublicKey) -> Self { + let cancel_output_descriptor = build_shared_output_descriptor(A.0, B.0); + + let tx_in = TxIn { + previous_output: tx_lock.as_outpoint(), + script_sig: Default::default(), + sequence: cancel_timelock, + witness: Vec::new(), + }; + + let tx_out = TxOut { + value: tx_lock.lock_amount().as_sat() - TX_FEE, + script_pubkey: cancel_output_descriptor.script_pubkey(), + }; + + let transaction = Transaction { + version: 2, + lock_time: 0, + input: vec![tx_in.clone()], + output: vec![tx_out], + }; + + let digest = SighashComponents::new(&transaction).sighash_all( + &tx_in, + &tx_lock.output_descriptor.witness_script(), + tx_lock.lock_amount().as_sat(), + ); + + Self { + inner: transaction, + digest, + output_descriptor: cancel_output_descriptor, + } + } + + pub fn digest(&self) -> SigHash { + self.digest + } + + fn amount(&self) -> Amount { + Amount::from_sat(self.inner.output[0].value) + } + + pub fn as_outpoint(&self) -> OutPoint { + OutPoint::new(self.inner.txid(), 0) + } + + pub fn add_signatures( + self, + tx_lock: &TxLock, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + key: A.0.into(), + }; + let B = ::bitcoin::PublicKey { + compressed: true, + key: B.0.into(), + }; + + // The order in which these are inserted doesn't matter + satisfier.insert(A, (sig_a.into(), ::bitcoin::SigHashType::All)); + satisfier.insert(B, (sig_b.into(), ::bitcoin::SigHashType::All)); + + satisfier + }; + + let mut tx_cancel = self.inner; + tx_lock + .output_descriptor + .satisfy(&mut tx_cancel.input[0], satisfier)?; + + Ok(tx_cancel) + } + + fn build_spend_transaction( + &self, + spend_address: &Address, + sequence: Option, + ) -> (Transaction, TxIn) { + let previous_output = self.as_outpoint(); + + let tx_in = TxIn { + previous_output, + script_sig: Default::default(), + sequence: sequence.unwrap_or(0xFFFF_FFFF), + witness: Vec::new(), + }; + + let tx_out = TxOut { + value: self.amount().as_sat() - TX_FEE, + script_pubkey: spend_address.script_pubkey(), + }; + + let transaction = Transaction { + version: 2, + lock_time: 0, + input: vec![tx_in.clone()], + output: vec![tx_out], + }; + + (transaction, tx_in) + } +} + +#[derive(Debug)] +pub struct TxRefund { + inner: Transaction, + digest: SigHash, +} + +impl TxRefund { + pub fn new(tx_cancel: &TxCancel, refund_address: &Address) -> Self { + let (tx_punish, cancel_input) = tx_cancel.build_spend_transaction(refund_address, None); + + let digest = SighashComponents::new(&tx_punish).sighash_all( + &cancel_input, + &tx_cancel.output_descriptor.witness_script(), + tx_cancel.amount().as_sat(), + ); + + Self { + inner: tx_punish, + digest, + } + } + + pub fn txid(&self) -> Txid { + self.inner.txid() + } + + pub fn digest(&self) -> SigHash { + self.digest + } + + pub fn add_signatures( + self, + tx_cancel: &TxCancel, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + key: A.0.into(), + }; + let B = ::bitcoin::PublicKey { + compressed: true, + key: B.0.into(), + }; + + // The order in which these are inserted doesn't matter + satisfier.insert(A, (sig_a.into(), ::bitcoin::SigHashType::All)); + satisfier.insert(B, (sig_b.into(), ::bitcoin::SigHashType::All)); + + satisfier + }; + + let mut tx_refund = self.inner; + tx_cancel + .output_descriptor + .satisfy(&mut tx_refund.input[0], satisfier)?; + + Ok(tx_refund) + } + + pub fn extract_signature_by_key( + &self, + candidate_transaction: Transaction, + B: PublicKey, + ) -> Result { + let input = match candidate_transaction.input.as_slice() { + [input] => input, + [] => bail!(NoInputs), + [inputs @ ..] => bail!(TooManyInputs(inputs.len())), + }; + + let sigs = match input + .witness + .iter() + .map(|vec| vec.as_slice()) + .collect::>() + .as_slice() + { + [sig_1, sig_2, _script] => [sig_1, sig_2] + .iter() + .map(|sig| { + bitcoin::secp256k1::Signature::from_der(&sig[..sig.len() - 1]) + .map(Signature::from) + }) + .collect::, _>>(), + [] => bail!(EmptyWitnessStack), + [witnesses @ ..] => bail!(NotThreeWitnesses(witnesses.len())), + }?; + + let sig = sigs + .into_iter() + .find(|sig| verify_sig(&B, &self.digest(), &sig).is_ok()) + .context("neither signature on witness stack verifies against B")?; + + Ok(sig) + } +} + +#[derive(Debug)] +pub struct TxPunish { + inner: Transaction, + digest: SigHash, +} + +impl TxPunish { + pub fn new(tx_cancel: &TxCancel, punish_address: &Address, punish_timelock: u32) -> Self { + let (tx_punish, lock_input) = + tx_cancel.build_spend_transaction(punish_address, Some(punish_timelock)); + + let digest = SighashComponents::new(&tx_punish).sighash_all( + &lock_input, + &tx_cancel.output_descriptor.witness_script(), + tx_cancel.amount().as_sat(), + ); + + Self { + inner: tx_punish, + digest, + } + } + + pub fn digest(&self) -> SigHash { + self.digest + } + + pub fn add_signatures( + self, + tx_cancel: &TxCancel, + (A, sig_a): (PublicKey, Signature), + (B, sig_b): (PublicKey, Signature), + ) -> Result { + let satisfier = { + let mut satisfier = HashMap::with_capacity(2); + + let A = ::bitcoin::PublicKey { + compressed: true, + key: A.0.into(), + }; + let B = ::bitcoin::PublicKey { + compressed: true, + key: B.0.into(), + }; + + // The order in which these are inserted doesn't matter + satisfier.insert(A, (sig_a.into(), ::bitcoin::SigHashType::All)); + satisfier.insert(B, (sig_b.into(), ::bitcoin::SigHashType::All)); + + satisfier + }; + + let mut tx_punish = self.inner; + tx_cancel + .output_descriptor + .satisfy(&mut tx_punish.input[0], satisfier)?; + + Ok(tx_punish) + } +} diff --git a/xmr-btc/src/bitcoin/wallet.rs b/xmr-btc/src/bitcoin/wallet.rs new file mode 100644 index 00000000..b357d823 --- /dev/null +++ b/xmr-btc/src/bitcoin/wallet.rs @@ -0,0 +1,116 @@ +use crate::bitcoin::{ + BroadcastSignedTransaction, BuildTxLockPsbt, GetRawTransaction, SignTxLock, TxLock, +}; +use anyhow::Result; +use async_trait::async_trait; +use bitcoin::{util::psbt::PartiallySignedTransaction, Address, Amount, Transaction, Txid}; +use bitcoin_harness::{bitcoind_rpc::PsbtBase64, Bitcoind}; +use reqwest::Url; +use std::time::Duration; +use tokio::time; + +#[derive(Debug)] +pub struct Wallet(pub bitcoin_harness::Wallet); + +impl Wallet { + pub async fn new(name: &str, url: &Url) -> Result { + let wallet = bitcoin_harness::Wallet::new(name, url.clone()).await?; + + Ok(Self(wallet)) + } + + pub async fn balance(&self) -> Result { + let balance = self.0.balance().await?; + Ok(balance) + } + + pub async fn new_address(&self) -> Result
{ + self.0.new_address().await.map_err(Into::into) + } + + pub async fn transaction_fee(&self, txid: Txid) -> Result { + let fee = self + .0 + .get_wallet_transaction(txid) + .await + .map(|res| bitcoin::Amount::from_btc(-res.fee))??; + + Ok(fee) + } +} + +pub async fn make_wallet( + name: &str, + bitcoind: &Bitcoind<'_>, + fund_amount: Amount, +) -> Result { + let wallet = Wallet::new(name, &bitcoind.node_url).await?; + let buffer = Amount::from_btc(1.0).unwrap(); + let amount = fund_amount + buffer; + + let address = wallet.0.new_address().await.unwrap(); + + bitcoind.mint(address, amount).await.unwrap(); + + Ok(wallet) +} + +#[async_trait] +impl BuildTxLockPsbt for Wallet { + async fn build_tx_lock_psbt( + &self, + output_address: Address, + output_amount: Amount, + ) -> Result { + let psbt = self.0.fund_psbt(output_address, output_amount).await?; + let as_hex = base64::decode(psbt)?; + + let psbt = bitcoin::consensus::deserialize(&as_hex)?; + + Ok(psbt) + } +} + +#[async_trait] +impl SignTxLock for Wallet { + async fn sign_tx_lock(&self, tx_lock: TxLock) -> Result { + let psbt = PartiallySignedTransaction::from(tx_lock); + + let psbt = bitcoin::consensus::serialize(&psbt); + let as_base64 = base64::encode(psbt); + + let psbt = self.0.wallet_process_psbt(PsbtBase64(as_base64)).await?; + let PsbtBase64(signed_psbt) = PsbtBase64::from(psbt); + + let as_hex = base64::decode(signed_psbt)?; + let psbt: PartiallySignedTransaction = bitcoin::consensus::deserialize(&as_hex)?; + + let tx = psbt.extract_tx(); + + Ok(tx) + } +} + +#[async_trait] +impl BroadcastSignedTransaction for Wallet { + async fn broadcast_signed_transaction(&self, transaction: Transaction) -> Result { + let txid = self.0.send_raw_transaction(transaction).await?; + + // TODO: Instead of guessing how long it will take for the transaction to be + // mined we should ask bitcoind for the number of confirmations on `txid` + + // give time for transaction to be mined + time::delay_for(Duration::from_millis(1100)).await; + + Ok(txid) + } +} + +#[async_trait] +impl GetRawTransaction for Wallet { + async fn get_raw_transaction(&self, txid: Txid) -> Result { + let tx = self.0.get_raw_transaction(txid).await?; + + Ok(tx) + } +} diff --git a/xmr-btc/src/bob.rs b/xmr-btc/src/bob.rs new file mode 100644 index 00000000..25fcfadb --- /dev/null +++ b/xmr-btc/src/bob.rs @@ -0,0 +1,472 @@ +use crate::{ + alice, + bitcoin::{self, BuildTxLockPsbt, GetRawTransaction, TxCancel}, + monero, +}; +use anyhow::{anyhow, Result}; +use ecdsa_fun::{ + adaptor::{Adaptor, EncryptedSignature}, + nonce::Deterministic, + Signature, +}; +use rand::{CryptoRng, RngCore}; +use sha2::Sha256; + +#[derive(Debug)] +pub struct Message0 { + pub(crate) B: bitcoin::PublicKey, + pub(crate) S_b_monero: monero::PublicKey, + pub(crate) S_b_bitcoin: bitcoin::PublicKey, + pub(crate) dleq_proof_s_b: cross_curve_dleq::Proof, + pub(crate) v_b: monero::PrivateViewKey, + pub(crate) refund_address: bitcoin::Address, +} + +#[derive(Debug)] +pub struct Message1 { + pub(crate) tx_lock: bitcoin::TxLock, +} + +#[derive(Debug)] +pub struct Message2 { + pub(crate) tx_punish_sig: Signature, + pub(crate) tx_cancel_sig: Signature, +} + +#[derive(Debug)] +pub struct Message3 { + pub(crate) tx_redeem_encsig: EncryptedSignature, +} + +#[derive(Debug)] +pub struct State0 { + b: bitcoin::SecretKey, + s_b: cross_curve_dleq::Scalar, + v_b: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, +} + +impl State0 { + pub fn new( + rng: &mut R, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + ) -> Self { + let b = bitcoin::SecretKey::new_random(rng); + + let s_b = cross_curve_dleq::Scalar::random(rng); + let v_b = monero::PrivateViewKey::new_random(rng); + + Self { + b, + s_b, + v_b, + btc, + xmr, + refund_timelock, + punish_timelock, + refund_address, + } + } + + pub fn next_message(&self, rng: &mut R) -> Message0 { + let dleq_proof_s_b = cross_curve_dleq::Proof::new(rng, &self.s_b); + + Message0 { + B: self.b.public(), + S_b_monero: monero::PublicKey::from_private_key(&monero::PrivateKey { + scalar: self.s_b.into_ed25519(), + }), + S_b_bitcoin: self.s_b.into_secp256k1().into(), + dleq_proof_s_b, + v_b: self.v_b, + refund_address: self.refund_address.clone(), + } + } + + pub async fn receive(self, wallet: &W, msg: alice::Message0) -> anyhow::Result + where + W: BuildTxLockPsbt, + { + msg.dleq_proof_s_a.verify( + &msg.S_a_bitcoin.clone().into(), + msg.S_a_monero + .point + .decompress() + .ok_or_else(|| anyhow!("S_a is not a monero curve point"))?, + )?; + + let tx_lock = + bitcoin::TxLock::new(wallet, self.btc, msg.A.clone(), self.b.public()).await?; + let v = msg.v_a + self.v_b; + + Ok(State1 { + A: msg.A, + b: self.b, + s_b: self.s_b, + S_a_monero: msg.S_a_monero, + S_a_bitcoin: msg.S_a_bitcoin, + v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: msg.redeem_address, + punish_address: msg.punish_address, + tx_lock, + }) + } +} + +#[derive(Debug)] +pub struct State1 { + A: bitcoin::PublicKey, + b: bitcoin::SecretKey, + s_b: cross_curve_dleq::Scalar, + S_a_monero: monero::PublicKey, + S_a_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, +} + +impl State1 { + pub fn next_message(&self) -> Message1 { + Message1 { + tx_lock: self.tx_lock.clone(), + } + } + + pub fn receive(self, msg: alice::Message1) -> Result { + let tx_cancel = TxCancel::new( + &self.tx_lock, + self.refund_timelock, + self.A.clone(), + self.b.public(), + ); + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &self.refund_address); + + bitcoin::verify_sig(&self.A, &tx_cancel.digest(), &msg.tx_cancel_sig)?; + bitcoin::verify_encsig( + self.A.clone(), + self.s_b.into_secp256k1().into(), + &tx_refund.digest(), + &msg.tx_refund_encsig, + )?; + + Ok(State2 { + A: self.A, + b: self.b, + s_b: self.s_b, + S_a_monero: self.S_a_monero, + S_a_bitcoin: self.S_a_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_cancel_sig_a: msg.tx_cancel_sig, + tx_refund_encsig: msg.tx_refund_encsig, + }) + } +} + +#[derive(Debug)] +pub struct State2 { + A: bitcoin::PublicKey, + b: bitcoin::SecretKey, + s_b: cross_curve_dleq::Scalar, + S_a_monero: monero::PublicKey, + S_a_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_cancel_sig_a: Signature, + tx_refund_encsig: EncryptedSignature, +} + +impl State2 { + pub fn next_message(&self) -> Message2 { + let tx_cancel = TxCancel::new( + &self.tx_lock, + self.refund_timelock, + self.A.clone(), + self.b.public(), + ); + let tx_cancel_sig = self.b.sign(tx_cancel.digest()); + let tx_punish = + bitcoin::TxPunish::new(&tx_cancel, &self.punish_address, self.punish_timelock); + let tx_punish_sig = self.b.sign(tx_punish.digest()); + + Message2 { + tx_punish_sig, + tx_cancel_sig, + } + } + + pub async fn lock_btc(self, bitcoin_wallet: &W) -> Result + where + W: bitcoin::SignTxLock + bitcoin::BroadcastSignedTransaction, + { + let signed_tx_lock = bitcoin_wallet.sign_tx_lock(self.tx_lock.clone()).await?; + + let _ = bitcoin_wallet + .broadcast_signed_transaction(signed_tx_lock) + .await?; + + Ok(State2b { + A: self.A, + b: self.b, + s_b: self.s_b, + S_a_monero: self.S_a_monero, + S_a_bitcoin: self.S_a_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_cancel_sig_a: self.tx_cancel_sig_a, + tx_refund_encsig: self.tx_refund_encsig, + }) + } +} + +#[derive(Debug, Clone)] +pub struct State2b { + A: bitcoin::PublicKey, + b: bitcoin::SecretKey, + s_b: cross_curve_dleq::Scalar, + S_a_monero: monero::PublicKey, + S_a_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_cancel_sig_a: Signature, + tx_refund_encsig: EncryptedSignature, +} + +impl State2b { + pub async fn watch_for_lock_xmr(self, xmr_wallet: &W, msg: alice::Message2) -> Result + where + W: monero::CheckTransfer, + { + let S_b_monero = monero::PublicKey::from_private_key(&monero::PrivateKey::from_scalar( + self.s_b.into_ed25519(), + )); + let S = self.S_a_monero + S_b_monero; + + xmr_wallet + .check_transfer(S, self.v.public(), msg.tx_lock_proof, self.xmr) + .await?; + + Ok(State3 { + A: self.A, + b: self.b, + s_b: self.s_b, + S_a_monero: self.S_a_monero, + S_a_bitcoin: self.S_a_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_cancel_sig_a: self.tx_cancel_sig_a, + tx_refund_encsig: self.tx_refund_encsig, + }) + } + + pub async fn refund_btc( + &self, + bitcoin_wallet: &W, + ) -> Result<()> { + let tx_cancel = bitcoin::TxCancel::new( + &self.tx_lock, + self.refund_timelock, + self.A.clone(), + self.b.public(), + ); + let tx_refund = bitcoin::TxRefund::new(&tx_cancel, &self.refund_address); + + { + let sig_b = self.b.sign(tx_cancel.digest()); + let sig_a = self.tx_cancel_sig_a.clone(); + + let signed_tx_cancel = tx_cancel.clone().add_signatures( + &self.tx_lock, + (self.A.clone(), sig_a), + (self.b.public(), sig_b), + )?; + + let _ = bitcoin_wallet + .broadcast_signed_transaction(signed_tx_cancel) + .await?; + } + + { + let adaptor = Adaptor::>::default(); + + let sig_b = self.b.sign(tx_refund.digest()); + let sig_a = adaptor + .decrypt_signature(&self.s_b.into_secp256k1(), self.tx_refund_encsig.clone()); + + let signed_tx_refund = tx_refund.add_signatures( + &tx_cancel.clone(), + (self.A.clone(), sig_a), + (self.b.public(), sig_b), + )?; + + let _ = bitcoin_wallet + .broadcast_signed_transaction(signed_tx_refund) + .await?; + } + Ok(()) + } + + #[cfg(test)] + pub fn tx_lock_id(&self) -> bitcoin::Txid { + self.tx_lock.txid() + } +} + +#[derive(Debug, Clone)] +pub struct State3 { + A: bitcoin::PublicKey, + b: bitcoin::SecretKey, + s_b: cross_curve_dleq::Scalar, + S_a_monero: monero::PublicKey, + S_a_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_cancel_sig_a: Signature, + tx_refund_encsig: EncryptedSignature, +} + +impl State3 { + pub fn next_message(&self) -> Message3 { + let tx_redeem = bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address); + let tx_redeem_encsig = self.b.encsign(self.S_a_bitcoin.clone(), tx_redeem.digest()); + + Message3 { tx_redeem_encsig } + } + + pub async fn watch_for_redeem_btc(self, bitcoin_wallet: &W) -> Result + where + W: GetRawTransaction, + { + let tx_redeem = bitcoin::TxRedeem::new(&self.tx_lock, &self.redeem_address); + let tx_redeem_encsig = self.b.encsign(self.S_a_bitcoin.clone(), tx_redeem.digest()); + + let tx_redeem_candidate = bitcoin_wallet.get_raw_transaction(tx_redeem.txid()).await?; + + 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.clone(), tx_redeem_sig, tx_redeem_encsig)?; + let s_a = + monero::PrivateKey::from_scalar(monero::Scalar::from_bytes_mod_order(s_a.to_bytes())); + + Ok(State4 { + A: self.A, + b: self.b, + s_a, + s_b: self.s_b, + S_a_monero: self.S_a_monero, + S_a_bitcoin: self.S_a_bitcoin, + v: self.v, + btc: self.btc, + xmr: self.xmr, + refund_timelock: self.refund_timelock, + punish_timelock: self.punish_timelock, + refund_address: self.refund_address, + redeem_address: self.redeem_address, + punish_address: self.punish_address, + tx_lock: self.tx_lock, + tx_refund_encsig: self.tx_refund_encsig, + tx_cancel_sig: self.tx_cancel_sig_a, + }) + } +} + +#[derive(Debug)] +pub struct State4 { + A: bitcoin::PublicKey, + b: bitcoin::SecretKey, + s_a: monero::PrivateKey, + s_b: cross_curve_dleq::Scalar, + S_a_monero: monero::PublicKey, + S_a_bitcoin: bitcoin::PublicKey, + v: monero::PrivateViewKey, + btc: bitcoin::Amount, + xmr: monero::Amount, + refund_timelock: u32, + punish_timelock: u32, + refund_address: bitcoin::Address, + redeem_address: bitcoin::Address, + punish_address: bitcoin::Address, + tx_lock: bitcoin::TxLock, + tx_refund_encsig: EncryptedSignature, + tx_cancel_sig: Signature, +} + +impl State4 { + pub async fn claim_xmr(&self, monero_wallet: &W) -> Result<()> + where + W: monero::ImportOutput, + { + let s_b = monero::PrivateKey { + scalar: self.s_b.into_ed25519(), + }; + + let s = self.s_a + s_b; + + // NOTE: This actually generates and opens a new wallet, closing the currently + // open one. + monero_wallet.import_output(s, self.v).await?; + + Ok(()) + } +} diff --git a/xmr-btc/src/lib.rs b/xmr-btc/src/lib.rs new file mode 100644 index 00000000..d4e66337 --- /dev/null +++ b/xmr-btc/src/lib.rs @@ -0,0 +1,384 @@ +#![warn( + unused_extern_crates, + missing_debug_implementations, + missing_copy_implementations, + rust_2018_idioms, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::fallible_impl_from, + clippy::cast_precision_loss, + clippy::cast_possible_wrap, + clippy::dbg_macro +)] +#![cfg_attr(not(test), warn(clippy::unwrap_used))] +#![forbid(unsafe_code)] +#![allow(non_snake_case)] + +pub mod alice; +pub mod bitcoin; +pub mod bob; +pub mod monero; + +#[cfg(test)] +mod tests { + use crate::{ + alice, bitcoin, + bitcoin::{Amount, TX_FEE}, + bob, monero, + }; + use bitcoin_harness::Bitcoind; + use monero_harness::Monero; + use rand::rngs::OsRng; + use testcontainers::clients::Cli; + + const TEN_XMR: u64 = 10_000_000_000_000; + + pub async fn init_bitcoind(tc_client: &Cli) -> Bitcoind<'_> { + let bitcoind = Bitcoind::new(tc_client, "0.19.1").expect("failed to create bitcoind"); + let _ = bitcoind.init(5).await; + + bitcoind + } + + #[tokio::test] + async fn happy_path() { + let cli = Cli::default(); + let monero = Monero::new(&cli); + let bitcoind = init_bitcoind(&cli).await; + + // must be bigger than our hardcoded fee of 10_000 + let btc_amount = bitcoin::Amount::from_sat(10_000_000); + let xmr_amount = monero::Amount::from_piconero(1_000_000_000_000); + + let fund_alice = TEN_XMR; + let fund_bob = 0; + monero.init(fund_alice, fund_bob).await.unwrap(); + + let alice_monero_wallet = monero::AliceWallet(&monero); + let bob_monero_wallet = monero::BobWallet(&monero); + + let alice_btc_wallet = bitcoin::Wallet::new("alice", &bitcoind.node_url) + .await + .unwrap(); + let bob_btc_wallet = bitcoin::make_wallet("bob", &bitcoind, btc_amount) + .await + .unwrap(); + + let alice_initial_btc_balance = alice_btc_wallet.balance().await.unwrap(); + let bob_initial_btc_balance = bob_btc_wallet.balance().await.unwrap(); + + let alice_initial_xmr_balance = alice_monero_wallet.0.get_balance_alice().await.unwrap(); + let bob_initial_xmr_balance = bob_monero_wallet.0.get_balance_bob().await.unwrap(); + + let redeem_address = alice_btc_wallet.new_address().await.unwrap(); + let punish_address = redeem_address.clone(); + let refund_address = bob_btc_wallet.new_address().await.unwrap(); + + let refund_timelock = 1; + let punish_timelock = 1; + + let alice_state0 = alice::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock, + punish_timelock, + redeem_address, + punish_address, + ); + let bob_state0 = bob::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock, + punish_timelock, + refund_address.clone(), + ); + + let alice_message0 = alice_state0.next_message(&mut OsRng); + let bob_message0 = bob_state0.next_message(&mut OsRng); + + let alice_state1 = alice_state0.receive(bob_message0).unwrap(); + let bob_state1 = bob_state0 + .receive(&bob_btc_wallet, alice_message0) + .await + .unwrap(); + + let bob_message1 = bob_state1.next_message(); + let alice_state2 = alice_state1.receive(bob_message1); + let alice_message1 = alice_state2.next_message(); + let bob_state2 = bob_state1.receive(alice_message1).unwrap(); + + let bob_message2 = bob_state2.next_message(); + let alice_state3 = alice_state2.receive(bob_message2).unwrap(); + + let bob_state2b = bob_state2.lock_btc(&bob_btc_wallet).await.unwrap(); + let lock_txid = bob_state2b.tx_lock_id(); + + let alice_state4 = alice_state3 + .watch_for_lock_btc(&alice_btc_wallet) + .await + .unwrap(); + + let (alice_state4b, lock_tx_monero_fee) = + alice_state4.lock_xmr(&alice_monero_wallet).await.unwrap(); + + let alice_message2 = alice_state4b.next_message(); + + let bob_state3 = bob_state2b + .watch_for_lock_xmr(&bob_monero_wallet, alice_message2) + .await + .unwrap(); + + let bob_message3 = bob_state3.next_message(); + let alice_state5 = alice_state4b.receive(bob_message3); + + alice_state5.redeem_btc(&alice_btc_wallet).await.unwrap(); + let bob_state4 = bob_state3 + .watch_for_redeem_btc(&bob_btc_wallet) + .await + .unwrap(); + + bob_state4.claim_xmr(&bob_monero_wallet).await.unwrap(); + + let alice_final_btc_balance = alice_btc_wallet.balance().await.unwrap(); + let bob_final_btc_balance = bob_btc_wallet.balance().await.unwrap(); + + let lock_tx_bitcoin_fee = bob_btc_wallet.transaction_fee(lock_txid).await.unwrap(); + + assert_eq!( + alice_final_btc_balance, + alice_initial_btc_balance + btc_amount - bitcoin::Amount::from_sat(bitcoin::TX_FEE) + ); + assert_eq!( + bob_final_btc_balance, + bob_initial_btc_balance - btc_amount - lock_tx_bitcoin_fee + ); + + let alice_final_xmr_balance = alice_monero_wallet.0.get_balance_alice().await.unwrap(); + bob_monero_wallet + .0 + .wait_for_bob_wallet_block_height() + .await + .unwrap(); + let bob_final_xmr_balance = bob_monero_wallet.0.get_balance_bob().await.unwrap(); + + assert_eq!( + alice_final_xmr_balance, + alice_initial_xmr_balance - u64::from(xmr_amount) - u64::from(lock_tx_monero_fee) + ); + assert_eq!( + bob_final_xmr_balance, + bob_initial_xmr_balance + u64::from(xmr_amount) + ); + } + + #[tokio::test] + async fn both_refund() { + let cli = Cli::default(); + let monero = Monero::new(&cli); + let bitcoind = init_bitcoind(&cli).await; + + // must be bigger than our hardcoded fee of 10_000 + let btc_amount = bitcoin::Amount::from_sat(10_000_000); + let xmr_amount = monero::Amount::from_piconero(1_000_000_000_000); + + let alice_btc_wallet = bitcoin::Wallet::new("alice", &bitcoind.node_url) + .await + .unwrap(); + let bob_btc_wallet = bitcoin::make_wallet("bob", &bitcoind, btc_amount) + .await + .unwrap(); + + let fund_alice = TEN_XMR; + let fund_bob = 0; + + monero.init(fund_alice, fund_bob).await.unwrap(); + let alice_monero_wallet = monero::AliceWallet(&monero); + let bob_monero_wallet = monero::BobWallet(&monero); + + let alice_initial_btc_balance = alice_btc_wallet.balance().await.unwrap(); + let bob_initial_btc_balance = bob_btc_wallet.balance().await.unwrap(); + + let bob_initial_xmr_balance = bob_monero_wallet.0.get_balance_bob().await.unwrap(); + + let redeem_address = alice_btc_wallet.new_address().await.unwrap(); + let punish_address = redeem_address.clone(); + let refund_address = bob_btc_wallet.new_address().await.unwrap(); + + let refund_timelock = 1; + let punish_timelock = 1; + + let alice_state0 = alice::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock, + punish_timelock, + redeem_address, + punish_address, + ); + let bob_state0 = bob::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock, + punish_timelock, + refund_address.clone(), + ); + + let alice_message0 = alice_state0.next_message(&mut OsRng); + let bob_message0 = bob_state0.next_message(&mut OsRng); + + let alice_state1 = alice_state0.receive(bob_message0).unwrap(); + let bob_state1 = bob_state0 + .receive(&bob_btc_wallet, alice_message0) + .await + .unwrap(); + + let bob_message1 = bob_state1.next_message(); + let alice_state2 = alice_state1.receive(bob_message1); + let alice_message1 = alice_state2.next_message(); + let bob_state2 = bob_state1.receive(alice_message1).unwrap(); + + let bob_message2 = bob_state2.next_message(); + let alice_state3 = alice_state2.receive(bob_message2).unwrap(); + + let bob_state2b = bob_state2.lock_btc(&bob_btc_wallet).await.unwrap(); + + let alice_state4 = alice_state3 + .watch_for_lock_btc(&alice_btc_wallet) + .await + .unwrap(); + + let (alice_state4b, _lock_tx_monero_fee) = + alice_state4.lock_xmr(&alice_monero_wallet).await.unwrap(); + + bob_state2b.refund_btc(&bob_btc_wallet).await.unwrap(); + + alice_state4b + .refund_xmr(&alice_btc_wallet, &alice_monero_wallet) + .await + .unwrap(); + + let alice_final_btc_balance = alice_btc_wallet.balance().await.unwrap(); + let bob_final_btc_balance = bob_btc_wallet.balance().await.unwrap(); + + // lock_tx_bitcoin_fee is determined by the wallet, it is not necessarily equal + // to TX_FEE + let lock_tx_bitcoin_fee = bob_btc_wallet + .transaction_fee(bob_state2b.tx_lock_id()) + .await + .unwrap(); + + assert_eq!(alice_final_btc_balance, alice_initial_btc_balance); + assert_eq!( + bob_final_btc_balance, + // The 2 * TX_FEE corresponds to tx_refund and tx_cancel. + bob_initial_btc_balance - Amount::from_sat(2 * TX_FEE) - lock_tx_bitcoin_fee + ); + + alice_monero_wallet + .0 + .wait_for_alice_wallet_block_height() + .await + .unwrap(); + let alice_final_xmr_balance = alice_monero_wallet.0.get_balance_alice().await.unwrap(); + let bob_final_xmr_balance = bob_monero_wallet.0.get_balance_bob().await.unwrap(); + + // Because we create a new wallet when claiming Monero, we can only assert on + // this new wallet owning all of `xmr_amount` after refund + assert_eq!(alice_final_xmr_balance, u64::from(xmr_amount)); + assert_eq!(bob_final_xmr_balance, bob_initial_xmr_balance); + } + + #[tokio::test] + async fn alice_punishes() { + let cli = Cli::default(); + let bitcoind = init_bitcoind(&cli).await; + + // must be bigger than our hardcoded fee of 10_000 + let btc_amount = bitcoin::Amount::from_sat(10_000_000); + let xmr_amount = monero::Amount::from_piconero(1_000_000_000_000); + + let alice_btc_wallet = bitcoin::Wallet::new("alice", &bitcoind.node_url) + .await + .unwrap(); + let bob_btc_wallet = bitcoin::make_wallet("bob", &bitcoind, btc_amount) + .await + .unwrap(); + + let alice_initial_btc_balance = alice_btc_wallet.balance().await.unwrap(); + let bob_initial_btc_balance = bob_btc_wallet.balance().await.unwrap(); + + let redeem_address = alice_btc_wallet.new_address().await.unwrap(); + let punish_address = redeem_address.clone(); + let refund_address = bob_btc_wallet.new_address().await.unwrap(); + + let refund_timelock = 1; + let punish_timelock = 1; + + let alice_state0 = alice::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock, + punish_timelock, + redeem_address, + punish_address, + ); + let bob_state0 = bob::State0::new( + &mut OsRng, + btc_amount, + xmr_amount, + refund_timelock, + punish_timelock, + refund_address.clone(), + ); + + let alice_message0 = alice_state0.next_message(&mut OsRng); + let bob_message0 = bob_state0.next_message(&mut OsRng); + + let alice_state1 = alice_state0.receive(bob_message0).unwrap(); + let bob_state1 = bob_state0 + .receive(&bob_btc_wallet, alice_message0) + .await + .unwrap(); + + let bob_message1 = bob_state1.next_message(); + let alice_state2 = alice_state1.receive(bob_message1); + let alice_message1 = alice_state2.next_message(); + let bob_state2 = bob_state1.receive(alice_message1).unwrap(); + + let bob_message2 = bob_state2.next_message(); + let alice_state3 = alice_state2.receive(bob_message2).unwrap(); + + let bob_state2b = bob_state2.lock_btc(&bob_btc_wallet).await.unwrap(); + + let alice_state4 = alice_state3 + .watch_for_lock_btc(&alice_btc_wallet) + .await + .unwrap(); + + alice_state4.punish(&alice_btc_wallet).await.unwrap(); + + let alice_final_btc_balance = alice_btc_wallet.balance().await.unwrap(); + let bob_final_btc_balance = bob_btc_wallet.balance().await.unwrap(); + + // lock_tx_bitcoin_fee is determined by the wallet, it is not necessarily equal + // to TX_FEE + let lock_tx_bitcoin_fee = bob_btc_wallet + .transaction_fee(bob_state2b.tx_lock_id()) + .await + .unwrap(); + + assert_eq!( + alice_final_btc_balance, + alice_initial_btc_balance + btc_amount - Amount::from_sat(2 * TX_FEE) + ); + assert_eq!( + bob_final_btc_balance, + bob_initial_btc_balance - btc_amount - lock_tx_bitcoin_fee + ); + } +} diff --git a/xmr-btc/src/monero.rs b/xmr-btc/src/monero.rs new file mode 100644 index 00000000..b67caabd --- /dev/null +++ b/xmr-btc/src/monero.rs @@ -0,0 +1,123 @@ +#[cfg(test)] +pub mod wallet; + +use std::ops::Add; + +use anyhow::Result; +use async_trait::async_trait; +use rand::{CryptoRng, RngCore}; + +pub use curve25519_dalek::scalar::Scalar; +pub use monero::{Address, PrivateKey, PublicKey}; + +pub fn random_private_key(rng: &mut R) -> PrivateKey { + let scalar = Scalar::random(rng); + + PrivateKey::from_scalar(scalar) +} + +#[cfg(test)] +pub use wallet::{AliceWallet, BobWallet}; + +#[derive(Clone, Copy, Debug)] +pub struct PrivateViewKey(PrivateKey); + +impl PrivateViewKey { + pub fn new_random(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 for PrivateKey { + fn from(from: PrivateViewKey) -> Self { + from.0 + } +} + +impl From for PublicKey { + fn from(from: PublicViewKey) -> Self { + from.0 + } +} + +#[derive(Clone, Copy, Debug)] +pub struct PublicViewKey(PublicKey); + +#[derive(Debug, Copy, Clone)] +pub struct Amount(u64); + +impl Amount { + /// 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 fn from_piconero(amount: u64) -> Self { + Amount(amount) + } +} + +impl From for u64 { + fn from(from: Amount) -> u64 { + from.0 + } +} + +#[derive(Clone, Debug)] +pub struct TransferProof { + tx_hash: TxHash, + tx_key: PrivateKey, +} + +#[derive(Clone, Debug)] +pub struct TxHash(String); + +impl From for String { + fn from(from: TxHash) -> Self { + from.0 + } +} + +#[async_trait] +pub trait Transfer { + async fn transfer( + &self, + public_spend_key: PublicKey, + public_view_key: PublicViewKey, + amount: Amount, + ) -> Result<(TransferProof, Amount)>; +} + +#[async_trait] +pub trait CheckTransfer { + async fn check_transfer( + &self, + public_spend_key: PublicKey, + public_view_key: PublicViewKey, + transfer_proof: TransferProof, + amount: Amount, + ) -> Result<()>; +} + +#[async_trait] +pub trait ImportOutput { + async fn import_output( + &self, + private_spend_key: PrivateKey, + private_view_key: PrivateViewKey, + ) -> Result<()>; +} diff --git a/xmr-btc/src/monero/wallet.rs b/xmr-btc/src/monero/wallet.rs new file mode 100644 index 00000000..2834f134 --- /dev/null +++ b/xmr-btc/src/monero/wallet.rs @@ -0,0 +1,125 @@ +use crate::monero::{ + Amount, CheckTransfer, ImportOutput, PrivateViewKey, PublicKey, PublicViewKey, Transfer, + TransferProof, TxHash, +}; +use anyhow::{bail, Result}; +use async_trait::async_trait; +use monero::{Address, Network, PrivateKey}; +use monero_harness::Monero; +use std::str::FromStr; + +#[derive(Debug)] +pub struct AliceWallet<'c>(pub &'c Monero<'c>); + +#[async_trait] +impl Transfer for AliceWallet<'_> { + async fn transfer( + &self, + public_spend_key: PublicKey, + public_view_key: PublicViewKey, + amount: Amount, + ) -> Result<(TransferProof, Amount)> { + let destination_address = + Address::standard(Network::Mainnet, public_spend_key, public_view_key.into()); + + let res = self + .0 + .transfer_from_alice(amount.0, &destination_address.to_string()) + .await?; + + let tx_hash = TxHash(res.tx_hash); + let tx_key = PrivateKey::from_str(&res.tx_key)?; + + let fee = Amount::from_piconero(res.fee); + + Ok((TransferProof { tx_hash, tx_key }, fee)) + } +} + +#[derive(Debug)] +pub struct BobWallet<'c>(pub &'c Monero<'c>); + +#[async_trait] +impl CheckTransfer for BobWallet<'_> { + async fn check_transfer( + &self, + public_spend_key: PublicKey, + public_view_key: PublicViewKey, + transfer_proof: TransferProof, + amount: Amount, + ) -> Result<()> { + let address = Address::standard(Network::Mainnet, public_spend_key, public_view_key.into()); + + let cli = self.0.bob_wallet_rpc_client(); + + let res = cli + .check_tx_key( + &String::from(transfer_proof.tx_hash), + &transfer_proof.tx_key.to_string(), + &address.to_string(), + ) + .await?; + + if res.received != u64::from(amount) { + bail!( + "tx_lock doesn't pay enough: expected {:?}, got {:?}", + res.received, + amount + ) + } + + Ok(()) + } +} + +#[async_trait] +impl ImportOutput for BobWallet<'_> { + async fn import_output( + &self, + private_spend_key: PrivateKey, + private_view_key: PrivateViewKey, + ) -> Result<()> { + let public_spend_key = PublicKey::from_private_key(&private_spend_key); + let public_view_key = PublicKey::from_private_key(&private_view_key.into()); + + let address = Address::standard(Network::Mainnet, public_spend_key, public_view_key); + + let _ = self + .0 + .bob_wallet_rpc_client() + .generate_from_keys( + &address.to_string(), + &private_spend_key.to_string(), + &PrivateKey::from(private_view_key).to_string(), + ) + .await?; + + Ok(()) + } +} + +#[async_trait] +impl ImportOutput for AliceWallet<'_> { + async fn import_output( + &self, + private_spend_key: PrivateKey, + private_view_key: PrivateViewKey, + ) -> Result<()> { + let public_spend_key = PublicKey::from_private_key(&private_spend_key); + let public_view_key = PublicKey::from_private_key(&private_view_key.into()); + + let address = Address::standard(Network::Mainnet, public_spend_key, public_view_key); + + let _ = self + .0 + .alice_wallet_rpc_client() + .generate_from_keys( + &address.to_string(), + &private_spend_key.to_string(), + &PrivateKey::from(private_view_key).to_string(), + ) + .await?; + + Ok(()) + } +}