diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7c961ac3..9a8dc17ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,10 @@ jobs: test_name: alice_empty_balance_after_started_btc_early_refund - package: swap test_name: alice_broken_wallet_rpc_after_started_btc_early_refund + - package: swap + test_name: manual_cooperative_redeem + - package: swap + test_name: manual_cooperative_redeem_malicious_key.rs runs-on: ubuntu-22.04 if: github.event_name == 'push' || !github.event.pull_request.draft diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ee485c36..8dcc918ca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -78,4 +78,4 @@ "rust-analyzer.cargo.extraEnv": { "CARGO_TARGET_DIR": "target-check" } -} +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a0d453e3f..413cd1ec8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8406,12 +8406,13 @@ dependencies = [ [[package]] name = "priority-queue" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e7f4ffd8645efad783fc2844ac842367aa2e912d484950192564d57dc039a3a" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", "indexmap 2.11.4", + "serde", ] [[package]] @@ -9913,9 +9914,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -9974,18 +9975,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -11030,7 +11031,6 @@ dependencies = [ "async-compression 0.3.15", "async-trait", "asynchronous-codec 0.7.0", - "atty", "backoff", "base64 0.22.1", "bdk", @@ -11064,6 +11064,7 @@ dependencies = [ "moka", "monero", "monero-harness", + "monero-oxide", "monero-rpc 0.1.0", "monero-rpc 0.1.0 (git+https://github.com/monero-oxide/monero-oxide)", "monero-rpc-pool", @@ -11163,6 +11164,7 @@ dependencies = [ "shell-words", "swap-controller-api", "tokio", + "uuid", ] [[package]] @@ -11171,9 +11173,10 @@ version = "0.1.0" dependencies = [ "bitcoin 0.32.7", "jsonrpsee", + "monero", "serde", - "serde_json", - "tokio", + "swap-serde", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 640b1135d..b4fd1aa67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,14 @@ bdk_electrum = { version = "0.23.0", default-features = false } bdk_wallet = "2.0.0" bitcoin = { version = "0.32", features = ["rand", "serde"] } +# Crypto +curve25519-dalek = { package = "curve25519-dalek-ng", version = "4" } + +# Monero +monero-oxide = { git = "https://github.com/monero-oxide/monero-oxide", default-features = false, features = ["std"] } + +swap-serde = { path = "./swap-serde" } + anyhow = "1" backoff = { version = "0.4", features = ["futures", "tokio"] } futures = { version = "0.3", default-features = false, features = ["std"] } diff --git a/justfile b/justfile index f075c0faf..14ce624ad 100644 --- a/justfile +++ b/justfile @@ -75,9 +75,17 @@ swap: cargo build -p swap-asb --bin asb && cd swap && cargo build --bin=swap # Run the asb on testnet -asb-testnet: +asb: ASB_DEV_ADDR_OUTPUT_PATH="$(pwd)/src-gui/.env.development" cargo run -p swap-asb --bin asb -- --trace --testnet start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 +# Run the asb on mainnet (only use for testing) +asb-mainnet: + cargo run -p swap-asb --bin asb -- --trace start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 + +# Run the asb controller connecting to your already running asb +asb-controller: + cargo run -p swap-controller --bin asb-controller -- --url http://127.0.0.1:9944 + # Updates our submodules (currently only Monero C++ codebase) update_submodules: cd monero-sys && git submodule update --init --recursive --force diff --git a/monero-rpc-pool/src/proxy.rs b/monero-rpc-pool/src/proxy.rs index 85e37f7bf..3dba5db11 100644 --- a/monero-rpc-pool/src/proxy.rs +++ b/monero-rpc-pool/src/proxy.rs @@ -16,7 +16,6 @@ use tokio::{ }; use tokio_rustls::rustls::{ - self, client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, pki_types::{CertificateDer, ServerName, UnixTime}, DigitallySignedStruct, Error as TlsError, SignatureScheme, diff --git a/monero-seed/Cargo.toml b/monero-seed/Cargo.toml index 04b6466b4..4cf612160 100644 --- a/monero-seed/Cargo.toml +++ b/monero-seed/Cargo.toml @@ -24,7 +24,7 @@ curve25519-dalek = { version = "4", default-features = false, features = ["alloc [dev-dependencies] hex = { version = "0.4", default-features = false, features = ["std"] } -monero-oxide = { git = "https://github.com/monero-oxide/monero-oxide", default-features = false, features = ["std"] } +monero-oxide = { workspace = true } [features] std = [ diff --git a/monero-seed/src/tests.rs b/monero-seed/src/tests.rs index fb66dc496..152616ed5 100644 --- a/monero-seed/src/tests.rs +++ b/monero-seed/src/tests.rs @@ -3,7 +3,7 @@ use zeroize::Zeroizing; use curve25519_dalek::scalar::Scalar; -use monero_primitives::keccak256; +use monero_oxide::primitives::keccak256; use crate::*; diff --git a/monero-sys/build.rs b/monero-sys/build.rs index 62bff9442..319586ee5 100644 --- a/monero-sys/build.rs +++ b/monero-sys/build.rs @@ -110,7 +110,7 @@ fn main() { .display() .to_string(); config.define("CMAKE_TOOLCHAIN_FILE", toolchain_file.clone()); - println!("cargo:warning=Using toolchain file: {}", toolchain_file); + println!("cargo:debug=Using toolchain file: {}", toolchain_file); let depends_lib_dir = contrib_depends_dir.join(format!("{}/lib", target)); @@ -414,7 +414,7 @@ fn compile_dependencies( "aarch64-apple-ios-sim" => "aarch64-apple-iossimulator".to_string(), _ => target, }; - println!("cargo:warning=Building for target: {}", target); + println!("cargo:debug=Building for target: {}", target); match target.as_str() { "x86_64-apple-darwin" @@ -431,7 +431,7 @@ fn compile_dependencies( } println!( - "cargo:warning=Running make HOST={} in contrib/depends", + "cargo:debug=Running make HOST={} in contrib/depends", target ); @@ -496,14 +496,14 @@ fn execute_child_with_pipe( // Spawn threads to handle stdout and stderr let stdout_handle = thread::spawn(move || { let reader = BufReader::new(stdout); - for line in reader.lines().flatten() { + for line in reader.lines().map_while(Result::ok) { println!("cargo:debug={}{}", &prefix_clone, line); } }); let stderr_handle = thread::spawn(move || { let reader = BufReader::new(stderr); - for line in reader.lines().flatten() { + for line in reader.lines().map_while(Result::ok) { println!("cargo:debug={}{}", &prefix, line); } }); @@ -528,7 +528,7 @@ fn apply_patches() -> Result<(), Box> { for embedded in EMBEDDED_PATCHES { println!( - "cargo:warning=Processing embedded patch: {} ({})", + "cargo:debug=Processing embedded patch: {} ({})", embedded.name, embedded.description ); @@ -541,14 +541,14 @@ fn apply_patches() -> Result<(), Box> { } println!( - "cargo:warning=Found {} file(s) in patch {}", + "cargo:debug=Found {} file(s) in patch {}", file_patches.len(), embedded.name ); // Apply each file patch individually for (file_path, patch_content) in file_patches { - println!("cargo:warning=Applying patch to file: {}", file_path); + println!("cargo:debug=Applying patch to file: {}", file_path); // Parse the individual file patch let patch = diffy::Patch::from_str(&patch_content) @@ -566,7 +566,7 @@ fn apply_patches() -> Result<(), Box> { // Check if patch is already applied by trying to reverse it if diffy::apply(¤t, &patch.reverse()).is_ok() { println!( - "cargo:warning=Patch for {} already applied – skipping", + "cargo:debug=Patch for {} already applied – skipping", file_path ); continue; @@ -578,11 +578,11 @@ fn apply_patches() -> Result<(), Box> { fs::write(&target_path, patched) .map_err(|e| format!("Failed to write {}: {}", file_path, e))?; - println!("cargo:warning=Successfully applied patch to: {}", file_path); + println!("cargo:debug=Successfully applied patch to: {}", file_path); } println!( - "cargo:warning=Successfully applied all file patches for: {} ({})", + "cargo:debug=Successfully applied all file patches for: {} ({})", embedded.name, embedded.description ); } diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index 6d9347103..a3bd6851d 100644 --- a/monero-sys/src/lib.rs +++ b/monero-sys/src/lib.rs @@ -152,10 +152,10 @@ pub struct Daemon { pub ssl: bool, } -impl TryFrom for Daemon { +impl<'a> TryFrom<&'a str> for Daemon { type Error = anyhow::Error; - fn try_from(address: String) -> Result { + fn try_from(address: &'a str) -> Result { let url = Url::parse(&address).context("Failed to parse daemon URL")?; let hostname = url @@ -177,6 +177,14 @@ impl TryFrom for Daemon { } } +impl<'a> TryFrom<&'a String> for Daemon { + type Error = anyhow::Error; + + fn try_from(value: &'a String) -> std::result::Result { + Daemon::try_from(value.as_str()) + } +} + impl Daemon { /// Try to convert the daemon configuration to a URL pub fn to_url_string(&self) -> String { diff --git a/monero-sys/tests/sign_message.rs b/monero-sys/tests/sign_message.rs index 48c31bf17..0f97ed851 100644 --- a/monero-sys/tests/sign_message.rs +++ b/monero-sys/tests/sign_message.rs @@ -10,10 +10,7 @@ async fn test_sign_message() { .init(); let temp_dir = tempfile::tempdir().unwrap(); - let daemon = Daemon { - address: PLACEHOLDER_NODE.into(), - ssl: false, - }; + let daemon = Daemon::try_from(PLACEHOLDER_NODE).unwrap(); let wallet_name = "test_signing_wallet"; let wallet_path = temp_dir.path().join(wallet_name).display().to_string(); diff --git a/monero-sys/tests/simple.rs b/monero-sys/tests/simple.rs index 4994900fe..496d76354 100644 --- a/monero-sys/tests/simple.rs +++ b/monero-sys/tests/simple.rs @@ -15,10 +15,7 @@ async fn main() { .init(); let temp_dir = tempfile::tempdir().unwrap(); - let daemon = Daemon { - address: STAGENET_REMOTE_NODE.into(), - ssl: true, - }; + let daemon = Daemon::try_from(STAGENET_REMOTE_NODE).unwrap(); let wallet_name = "recovered_wallet"; let wallet_path = temp_dir.path().join(wallet_name).display().to_string(); diff --git a/monero-sys/tests/special_paths.rs b/monero-sys/tests/special_paths.rs index a3e6ed511..c3c3cb0da 100644 --- a/monero-sys/tests/special_paths.rs +++ b/monero-sys/tests/special_paths.rs @@ -13,10 +13,7 @@ async fn test_wallet_with_special_paths() { "path-with-hyphen", ]; - let daemon = Daemon { - address: "https://moneronode.org:18081".into(), - ssl: true, - }; + let daemon = Daemon::try_from("https://moneronode.org:18081").unwrap(); let futures = special_paths .into_iter() diff --git a/monero-sys/tests/wallet_closing.rs b/monero-sys/tests/wallet_closing.rs index 25d11f44c..af719450b 100644 --- a/monero-sys/tests/wallet_closing.rs +++ b/monero-sys/tests/wallet_closing.rs @@ -10,10 +10,7 @@ async fn main() { .init(); let temp_dir = tempfile::tempdir().unwrap(); - let daemon = Daemon { - address: STAGENET_REMOTE_NODE.into(), - ssl: true, - }; + let daemon = Daemon::try_from(STAGENET_REMOTE_NODE).unwrap(); { let wallet = WalletHandle::open_or_create( diff --git a/src-gui/package.json b/src-gui/package.json index c1983e336..967df85c8 100644 --- a/src-gui/package.json +++ b/src-gui/package.json @@ -27,7 +27,7 @@ "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-cli": "^2.4.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", - "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-dialog": "2.4.0", "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-shell": "^2.3.0", diff --git a/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx b/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx index c321ba9b0..199a7e44d 100644 --- a/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx +++ b/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx @@ -93,24 +93,34 @@ export default function ActionableMonospaceTextBox({ - {content} - {displayCopyIcon && ( - - - - )} + + {content} + {displayCopyIcon && ( + + + + )} + + {enableQrCode && ( - - {spoilerText} - + {spoilerText} )} diff --git a/src-gui/src/renderer/components/other/MonospaceTextBox.tsx b/src-gui/src/renderer/components/other/MonospaceTextBox.tsx index 6a95c499d..5c1059f59 100644 --- a/src-gui/src/renderer/components/other/MonospaceTextBox.tsx +++ b/src-gui/src/renderer/components/other/MonospaceTextBox.tsx @@ -27,6 +27,7 @@ export default function MonospaceTextBox({ children, light = false }: Props) { lineHeight: 1.5, display: "flex", alignItems: "center", + flex: 1, }} > {children} diff --git a/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx b/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx index c5f76dc2c..0173d0cb5 100644 --- a/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx +++ b/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx @@ -26,7 +26,7 @@ export default function SeedPhraseButton({ }; return ( - + void; - seed: [GetMoneroSeedResponse, GetRestoreHeightResponse] | null; + open: boolean; +} + +interface SeedInfo { + seed: string; + restoreHeight: number; } export default function SeedPhraseModal({ onClose, - seed, + open, }: SeedPhraseModalProps) { - if (seed === null) { - return null; - } + const [info, setInfo] = useState(null); + + // Fetch seed and restore height on mount + useEffect(() => { + getMoneroSeedAndRestoreHeight().then(([seed, height]) => { + setInfo({ seed: seed.seed, restoreHeight: height.height }); + }); + }, []); return ( - - Wallet Seed Phrase + + Export your Monero wallet's seed + + Never reveal your seed phrase to anyone. The developers will + never ask for your seed. + + + Seed phrase + + + + The seed phrase of your wallet is equivalent to the secret key. + + + Restore Block Height + + + + The restore height will help other wallets determine which parts of + the blockchain to scan for funds. + - Keep this seed phrase safe and secure. Write it down on paper and - store it in a safe place. Keep the restore height in mind when you - restore your wallet on another device. - + > + + + + + ); +} diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 4dfc43ef9..1e4252177 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -46,6 +46,8 @@ import { GetRestoreHeightResponse, MoneroNodeConfig, GetMoneroSeedResponse, + ManualCooperativeRedeemArgs, + ManualCooperativeRedeemResponse, } from "models/tauriModel"; import { rpcSetBalance, @@ -257,6 +259,23 @@ export async function buyXmr( }); } +export async function resumeWithCooperativeRedeem( + swapId: string, + s_a: string, + txId: string, + txKey: string, +) { + await invoke( + "resume_with_cooperative_redeem", + { + swap_id: swapId, + s_a, + lock_tx_id: txId, + lock_tx_key: txKey, + }, + ); +} + export async function resumeSwap(swapId: string) { await invoke("resume_swap", { swap_id: swapId, @@ -356,7 +375,7 @@ export async function initializeContext() { enable_monero_tor: useMoneroTor, }; - logger.info("Initializing context with settings", tauriSettings); + logger.info(`Initializing context with settings ${tauriSettings}`); try { await invokeUnsafe("initialize_context", { diff --git a/src-gui/src/store/config.ts b/src-gui/src/store/config.ts index 4b77152aa..1f0729d8b 100644 --- a/src-gui/src/store/config.ts +++ b/src-gui/src/store/config.ts @@ -6,7 +6,8 @@ import { Network } from "./features/settingsSlice"; let matches: CliMatches; try { matches = await getMatches(); -} catch { +} catch (e) { + console.error("couldn't get cli matches: " + e); matches = { args: {}, }; diff --git a/src-gui/yarn.lock b/src-gui/yarn.lock index db569fe86..0f2b112e0 100644 --- a/src-gui/yarn.lock +++ b/src-gui/yarn.lock @@ -1025,10 +1025,10 @@ dependencies: "@tauri-apps/api" "^2.6.0" -"@tauri-apps/plugin-dialog@^2.0.0": - version "2.3.3" - resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.3.3.tgz#0bfd78f1f754157b39295a33826afd41f769f28f" - integrity sha512-cWXB9QJDbLIA0v7I5QY183awazBEQNPhp19iPvrMZoJRX8SbFkhWFx1/q7zy7xGpXXzxz29qtq6z21Ho7W5Iew== +"@tauri-apps/plugin-dialog@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.4.0.tgz#e37388bd3197f920a9975e323a105a75cb55d648" + integrity sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w== dependencies: "@tauri-apps/api" "^2.8.0" diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index 462fc145f..d4b709613 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -1,15 +1,14 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "desktop-capability", - "description": "Capabilities for desktop windows", + "windows": [ + "main" + ], "platforms": [ "macOS", "windows", "linux" ], - "windows": [ - "main" - ], "permissions": [ "cli:default", "cli:allow-cli-matches" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d3e857a5..1f2c881c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,9 +13,10 @@ use swap::cli::{ GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, - GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, - RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, - SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, + GetSwapInfosAllArgs, ListSellersArgs, ManualCooperativeRedeemArgs, MoneroRecoveryArgs, + RedactArgs, RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, + ResumeSwapArgs, SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, + WithdrawBtcArgs, }, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, Context, ContextBuilder, @@ -185,6 +186,7 @@ pub fn run() { withdraw_btc, buy_xmr, resume_swap, + manual_cooperative_redeem, get_history, monero_recovery, get_logs, @@ -248,6 +250,7 @@ pub fn run() { tauri_command!(get_balance, BalanceArgs); tauri_command!(buy_xmr, BuyXmrArgs); tauri_command!(resume_swap, ResumeSwapArgs); +tauri_command!(manual_cooperative_redeem, ManualCooperativeRedeemArgs); tauri_command!(withdraw_btc, WithdrawBtcArgs); tauri_command!(monero_recovery, MoneroRecoveryArgs); tauri_command!(get_logs, GetLogsArgs); diff --git a/swap-asb/src/command.rs b/swap-asb/src/command.rs index 11ffa5587..711ce1bb5 100644 --- a/swap-asb/src/command.rs +++ b/swap-asb/src/command.rs @@ -161,6 +161,16 @@ where env_config: env_config(testnet), cmd: Command::Punish { swap_id }, }, + RawCommand::ManualRecovery(ManualRecovery::ExportCooperativeRedeemKey { swap_id }) => { + Arguments { + testnet, + json, + trace, + config_path: config_path(config, testnet)?, + env_config: env_config(testnet), + cmd: Command::ExportCooperativeRedeemKey { swap_id }, + } + } RawCommand::ManualRecovery(ManualRecovery::SafelyAbort { swap_id }) => Arguments { testnet, json, @@ -250,6 +260,9 @@ pub enum Command { SafelyAbort { swap_id: Uuid, }, + ExportCooperativeRedeemKey { + swap_id: Uuid, + }, ExportBitcoinWallet, ExportMoneroWallet, } @@ -391,6 +404,14 @@ pub enum ManualRecovery { #[structopt(flatten)] punish_params: RecoverCommandParams, }, + #[structopt(about = "Export the cooperative redeem information manually.")] + ExportCooperativeRedeemKey { + #[structopt( + long = "swap-id", + help = "The swap id for which to releave the cooperative redeem information." + )] + swap_id: Uuid, + }, #[structopt(about = "Safely Abort requires the swap to be in a state prior to locking XMR.")] SafelyAbort { #[structopt( diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index c5acaba48..764169bc3 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -18,6 +18,7 @@ use libp2p::Swarm; use monero_sys::Daemon; use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; +use serde::Serialize; use std::convert::TryInto; use std::env; use std::str::FromStr; @@ -465,6 +466,40 @@ pub async fn main() -> Result<()> { tracing::info!("Punish transaction successfully published with id {}", txid); } + Command::ExportCooperativeRedeemKey { swap_id } => { + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; + + let state = db + .get_state(swap_id) + .await + .context("Failed to query database for swap state")?; + + let (state3, transfer_proof) = match state { + State::Alice(AliceState::BtcPunished { state3, transfer_proof }) => (state3, transfer_proof), + State::Alice(otherwise) => bail!("Can't reveal cooperative redeem key because we haven't punished yet. Current state: {otherwise}"), + _ => bail!("Can't reveal cooperative redeem key because this is a Bob state") + }; + + // We need a small serialization wrapper around the Monero PrivateKeys + struct PrivateKeySerializationWrapper<'a>(&'a monero::PrivateKey); + impl<'a> Serialize for PrivateKeySerializationWrapper<'a> { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + swap_serde::monero::private_key::serialize(self.0, serializer) + } + } + + let s_a = serde_json::to_string(&PrivateKeySerializationWrapper( + &monero::PrivateKey::from_scalar(state3.s_a), + ))?; + let txid = transfer_proof.tx_hash().0; + let txkey = + serde_json::to_string(&PrivateKeySerializationWrapper(&transfer_proof.tx_key()))?; + + println!("Printing secret cooperative redeem key for swap {swap_id}.\ns_a: {s_a}\nlock transaction id: {txid}\nlock transaction key: {txkey}"); + } Command::SafelyAbort { swap_id } => { let db = open_db(db_file, AccessMode::ReadWrite, None).await?; diff --git a/swap-controller-api/Cargo.toml b/swap-controller-api/Cargo.toml index c6bc7e764..0528805cb 100644 --- a/swap-controller-api/Cargo.toml +++ b/swap-controller-api/Cargo.toml @@ -7,10 +7,9 @@ edition = "2021" bitcoin = { workspace = true } jsonrpsee = { workspace = true, features = ["macros", "server", "client-core", "http-client"] } serde = { workspace = true } -serde_json = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util"] } +monero = { workspace = true } +swap-serde = { workspace = true } +uuid = { workspace = true } [lints] workspace = true diff --git a/swap-controller-api/src/lib.rs b/swap-controller-api/src/lib.rs index 2e4523d6f..d88eba148 100644 --- a/swap-controller-api/src/lib.rs +++ b/swap-controller-api/src/lib.rs @@ -1,6 +1,8 @@ use jsonrpsee::proc_macros::rpc; use jsonrpsee::types::ErrorObjectOwned; +use monero::PrivateKey; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BitcoinBalanceResponse { @@ -45,6 +47,17 @@ pub struct MoneroSeedResponse { pub restore_height: u64, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CooperativeRedeemResponse { + /// Actual secret key needed for cooperative redeem by Bob + #[serde(with = "swap_serde::monero::private_key")] + pub s_a: PrivateKey, + // Also include the tx hash and key of the Monero lock tx such that Bob can just import without scanning. + pub lock_tx_id: String, + #[serde(with = "swap_serde::monero::private_key")] + pub lock_tx_key: PrivateKey, +} + #[rpc(client, server)] pub trait AsbApi { #[method(name = "check_connection")] @@ -65,4 +78,9 @@ pub trait AsbApi { async fn active_connections(&self) -> Result; #[method(name = "get_swaps")] async fn get_swaps(&self) -> Result, ErrorObjectOwned>; + #[method(name = "cooperative_redeem_info")] + async fn cooperative_redeem_info( + &self, + swap_id: Uuid, + ) -> Result, ErrorObjectOwned>; } diff --git a/swap-controller/Cargo.toml b/swap-controller/Cargo.toml index 5439f10d0..e97564468 100644 --- a/swap-controller/Cargo.toml +++ b/swap-controller/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } shell-words = "1.1" swap-controller-api = { path = "../swap-controller-api" } tokio = { workspace = true } +uuid = { workspace = true, features = ["serde"] } [lints] workspace = true diff --git a/swap-controller/src/cli.rs b/swap-controller/src/cli.rs index 5aca11289..a447b995e 100644 --- a/swap-controller/src/cli.rs +++ b/swap-controller/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; +use uuid::Uuid; #[derive(Parser)] #[command(name = "asb-controller")] @@ -33,4 +34,6 @@ pub enum Cmd { ActiveConnections, /// Get list of swaps GetSwaps, + /// Get the secret key needed for manual cooperative redeem + CooperativeRedeemKey { swap_id: Uuid }, } diff --git a/swap-controller/src/main.rs b/swap-controller/src/main.rs index 9124ddc1d..a6508558c 100644 --- a/swap-controller/src/main.rs +++ b/swap-controller/src/main.rs @@ -31,7 +31,7 @@ async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { } Cmd::BitcoinBalance => { let response = client.bitcoin_balance().await?; - println!("Current Bitcoin balance is {} BTC", response.balance); + println!("Current Bitcoin balance is {}", response.balance); } Cmd::MoneroBalance => { let response = client.monero_balance().await?; @@ -82,7 +82,22 @@ async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { } Cmd::BitcoinSeed => { let response = client.bitcoin_seed().await?; - println!("Descriptor (BIP-0382) containing the private keys of the internal Bitcoin wallet: \n{}", response.descriptor); + println!("Descriptor (BIP-0382) containing the private keys of the internal Bitcoin wallet:\n{}", response.descriptor); + } + Cmd::CooperativeRedeemKey { swap_id } => { + let response = client.cooperative_redeem_info(swap_id).await?; + + let Some(response) = response else { + println!("Couldn't find any swap with id {swap_id} in the database"); + return Ok(()); + }; + + println!("Cooperative redeem key:"); + println!("{}", response.s_a); + println!("Monero lock transaction id:"); + println!("{}", response.lock_tx_id); + println!("Monero lock transaction ley:"); + println!("{}", response.lock_tx_key); } } Ok(()) diff --git a/swap-env/src/prompt.rs b/swap-env/src/prompt.rs index 6a6c30fc0..79bd42903 100644 --- a/swap-env/src/prompt.rs +++ b/swap-env/src/prompt.rs @@ -1,15 +1,15 @@ use std::path::{Path, PathBuf}; use crate::defaults::{ - default_rendezvous_points, DEFAULT_MAX_BUY_AMOUNT, DEFAULT_MIN_BUY_AMOUNT, DEFAULT_SPREAD, + DEFAULT_MAX_BUY_AMOUNT, DEFAULT_MIN_BUY_AMOUNT, DEFAULT_SPREAD, default_rendezvous_points, }; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use console::Style; use dialoguer::Confirm; -use dialoguer::{theme::ColorfulTheme, Input, Select}; +use dialoguer::{Input, Select, theme::ColorfulTheme}; use libp2p::Multiaddr; -use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; +use rust_decimal::prelude::FromPrimitive; use url::Url; /// Prompt user for data directory @@ -73,7 +73,7 @@ pub fn electrum_rpc_urls(default_electrum_urls: &Vec) -> Result> { .default(true) .interact()? { - true => default_electrum_urls.clone(), + true => default_electrum_urls.to_vec(), false => Vec::new(), }; diff --git a/swap-feed/src/rate.rs b/swap-feed/src/rate.rs index cb29ad954..92011eeec 100644 --- a/swap-feed/src/rate.rs +++ b/swap-feed/src/rate.rs @@ -177,9 +177,8 @@ mod tests { .sell_quote(bitcoin::Amount::ONE_BTC) .unwrap(); - let xmr_factor = xmr_no_spread.into().as_piconero_decimal() - / xmr_with_spread.into().as_piconero_decimal() - - ONE; + let xmr_factor = + Decimal::from(xmr_no_spread.as_pico()) / Decimal::from(xmr_with_spread.as_pico()) - ONE; assert!(xmr_with_spread < xmr_no_spread); assert_eq!(xmr_factor.round_dp(8), TWO_PERCENT); // round to 8 decimal diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 6542a7848..c72dbf0a0 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -19,12 +19,24 @@ bdk_core = { workspace = true } bdk_electrum = { workspace = true, features = ["use-rustls-ring"] } bdk_wallet = { workspace = true, features = ["rusqlite", "test-utils"] } +# Monero +monero-oxide = { workspace = true } +monero = { workspace = true } +monero-rpc = { path = "../monero-rpc" } +monero-rpc-pool = { path = "../monero-rpc-pool" } +monero-seed = { version = "0.1.0", path = "../monero-seed" } +monero-sys = { path = "../monero-sys" } + +# monero-oxide +monero-oxide-rpc = { git = "https://github.com/monero-oxide/monero-oxide.git", package = "monero-rpc" } +monero-simple-request-rpc = { git = "https://github.com/monero-oxide/monero-oxide.git" } + + anyhow = { workspace = true } arti-client = { workspace = true, features = ["static-sqlite", "tokio", "rustls", "onion-service-service"] } async-compression = { version = "0.3", features = ["bzip2", "tokio"] } async-trait = "0.1" asynchronous-codec = "0.7.0" -atty = "0.2" backoff = { workspace = true } base64 = "0.22" big-bytes = "1" @@ -33,7 +45,7 @@ bmrng = "0.5.2" comfy-table = "7.1" config = { version = "0.14", default-features = false, features = ["toml"] } conquer-once = "0.4" -curve25519-dalek = { package = "curve25519-dalek-ng", version = "4" } +curve25519-dalek = { workspace = true } data-encoding = "2.6" derive_builder = "0.20.2" dfx-swiss-sdk = { workspace = true, optional = true } @@ -44,15 +56,10 @@ electrum-pool = { path = "../electrum-pool" } fns = "0.0.7" futures = { workspace = true } hex = { workspace = true } -jsonrpsee = { workspace = true, features = ["server"] } +jsonrpsee = { workspace = true, features = ["server", "client-core", "http-client"] } libp2p = { workspace = true, features = ["tcp", "yamux", "dns", "noise", "request-response", "ping", "rendezvous", "identify", "macros", "cbor", "json", "tokio", "serde", "rsa"] } libp2p-tor = { path = "../libp2p-tor", features = ["listen-onion-service"] } moka = { version = "0.12", features = ["sync", "future"] } -monero = { workspace = true } -monero-rpc = { path = "../monero-rpc" } -monero-rpc-pool = { path = "../monero-rpc-pool" } -monero-seed = { version = "0.1.0", path = "../monero-seed" } -monero-sys = { path = "../monero-sys" } once_cell = { workspace = true } pem = "3.0" proptest = "1" @@ -77,7 +84,7 @@ swap-controller-api = { path = "../swap-controller-api" } swap-env = { path = "../swap-env" } swap-feed = { path = "../swap-feed" } swap-fs = { path = "../swap-fs" } -swap-serde = { path = "../swap-serde" } +swap-serde = { workspace = true } tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false } thiserror = { workspace = true } throttle = { path = "../throttle" } @@ -101,9 +108,6 @@ uuid = { workspace = true, features = ["serde"] } void = "1" zeroize = "1.8.1" -# monero-oxide -monero-oxide-rpc = { git = "https://github.com/monero-oxide/monero-oxide.git", package = "monero-rpc" } -monero-simple-request-rpc = { git = "https://github.com/monero-oxide/monero-oxide.git" } [target.'cfg(not(windows))'.dependencies] tokio-tar = "0.3" diff --git a/swap/migrations/20250912182555_S_a_monero_in_bob_state4_and_state6.sql b/swap/migrations/20250912182555_S_a_monero_in_bob_state4_and_state6.sql new file mode 100644 index 000000000..9ba4fc26b --- /dev/null +++ b/swap/migrations/20250912182555_S_a_monero_in_bob_state4_and_state6.sql @@ -0,0 +1,106 @@ +-- This migration makes S_a_monero available in Bob::{State4, State6} by copying it from Bob::State2. + +UPDATE swap_states SET + state = json_insert( + state, + '$.Bob.XmrLocked.state4.S_a_monero', + ( + SELECT json_extract(states.state, '$.Bob.ExecutionSetupDone.state2.S_a_monero') + FROM swap_states AS states + WHERE + states.swap_id = swap_states.swap_id + AND json_extract(states.state, '$.Bob.ExecutionSetupDone') IS NOT NULL + LIMIT 1 + ) + ) +WHERE json_extract(state, '$.Bob.XmrLocked') IS NOT NULL; + +UPDATE swap_states SET + state = json_insert( + state, + '$.Bob.EncSigSent.state4.S_a_monero', + ( + SELECT json_extract(states.state, '$.Bob.ExecutionSetupDone.state2.S_a_monero') + FROM swap_states AS states + WHERE + states.swap_id = swap_states.swap_id + AND json_extract(states.state, '$.Bob.ExecutionSetupDone') IS NOT NULL + LIMIT 1 + ) + ) +WHERE json_extract(state, '$.Bob.EncSigSent') IS NOT NULL; + +UPDATE swap_states SET + state = json_insert( + state, + '$.Bob.BtcPunished.state.S_a_monero', + ( + SELECT json_extract(states.state, '$.Bob.ExecutionSetupDone.state2.S_a_monero') + FROM swap_states AS states + WHERE + states.swap_id = swap_states.swap_id + AND json_extract(states.state, '$.Bob.ExecutionSetupDone') IS NOT NULL + LIMIT 1 + ) + ) +WHERE json_extract(state, '$.Bob.BtcPunished') IS NOT NULL; + +UPDATE swap_states SET + state = json_insert( + state, + '$.Bob.CancelTimelockExpired.S_a_monero', + ( + SELECT json_extract(states.state, '$.Bob.ExecutionSetupDone.state2.S_a_monero') + FROM swap_states AS states + WHERE + states.swap_id = swap_states.swap_id + AND json_extract(states.state, '$.Bob.ExecutionSetupDone') IS NOT NULL + LIMIT 1 + ) + ) +WHERE json_extract(state, '$.Bob.CancelTimelockExpired') IS NOT NULL; + +UPDATE swap_states SET + state = json_insert( + state, + '$.Bob.BtcCancelled.S_a_monero', + ( + SELECT json_extract(states.state, '$.Bob.ExecutionSetupDone.state2.S_a_monero') + FROM swap_states AS states + WHERE + states.swap_id = swap_states.swap_id + AND json_extract(states.state, '$.Bob.ExecutionSetupDone') IS NOT NULL + LIMIT 1 + ) + ) +WHERE json_extract(state, '$.Bob.BtcCancelled') IS NOT NULL; + +UPDATE swap_states SET + state = json_insert( + state, + '$.Bob.BtcRefundPublished.S_a_monero', + ( + SELECT json_extract(states.state, '$.Bob.ExecutionSetupDone.state2.S_a_monero') + FROM swap_states AS states + WHERE + states.swap_id = swap_states.swap_id + AND json_extract(states.state, '$.Bob.ExecutionSetupDone') IS NOT NULL + LIMIT 1 + ) + ) +WHERE json_extract(state, '$.Bob.BtcRefundPublished') IS NOT NULL; + +UPDATE swap_states SET + state = json_insert( + state, + '$.Bob.BtcEarlyRefundPublished.S_a_monero', + ( + SELECT json_extract(states.state, '$.Bob.ExecutionSetupDone.state2.S_a_monero') + FROM swap_states AS states + WHERE + states.swap_id = swap_states.swap_id + AND json_extract(states.state, '$.Bob.ExecutionSetupDone') IS NOT NULL + LIMIT 1 + ) + ) +WHERE json_extract(state, '$.Bob.BtcEarlyRefundPublished') IS NOT NULL; diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 89f6f7ff9..93f5508c1 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -1189,7 +1189,7 @@ mod tests { rate.clone(), || async { Ok(balance) }, || async { Ok(reserved_items) }, - None, + Decimal::ZERO, ) .await .unwrap(); diff --git a/swap/src/asb/network.rs b/swap/src/asb/network.rs index 16c62b42a..5199eb31d 100644 --- a/swap/src/asb/network.rs +++ b/swap/src/asb/network.rs @@ -222,8 +222,15 @@ pub mod behaviour { let pingConfig = ping::Config::new().with_timeout(Duration::from_secs(60)); let behaviour = if rendezvous_nodes.is_empty() { + tracing::warn!( + "No rendezvous nodes specified. ASB might not be discoverable for takers." + ); None } else { + tracing::info!( + "Registering with the following rendezvouse nodes: {:?}", + &rendezvous_nodes + ); Some(rendezvous::Behaviour::new(identity, rendezvous_nodes)) }; @@ -278,13 +285,14 @@ pub mod rendezvous { use std::pin::Pin; use std::task::Context; - #[derive(Clone, PartialEq)] + #[derive(Clone, PartialEq, Debug)] enum ConnectionStatus { Disconnected, Dialling, Connected, } + #[derive(Debug)] enum RegistrationStatus { RegisterOnNextConnection, Pending, @@ -300,6 +308,7 @@ pub mod rendezvous { } /// A node running the rendezvous server protocol. + #[derive(Debug)] pub struct RendezvousNode { pub address: Multiaddr, connection_status: ConnectionStatus, @@ -351,6 +360,7 @@ pub mod rendezvous { node.set_registration(RegistrationStatus::Pending); let (namespace, peer_id, ttl) = (node.namespace.into(), node.peer_id, node.registration_ttl); + tracing::info!("Sending rendezvous register request"); self.inner.register(namespace, peer_id, ttl) } } @@ -441,9 +451,9 @@ pub mod rendezvous { node.set_connection(ConnectionStatus::Disconnected); } } - FromSwarm::DialFailure(peer) => { + FromSwarm::DialFailure(failure) => { // Update the connection status of the rendezvous node that failed to connect. - if let Some(peer_id) = peer.peer_id { + if let Some(peer_id) = failure.peer_id { if let Some(node) = self .rendezvous_nodes .iter_mut() @@ -451,6 +461,11 @@ pub mod rendezvous { { node.set_connection(ConnectionStatus::Disconnected); } + tracing::error!( + "Rendezvous dial failure towards `{}`: {}", + peer_id, + failure.error + ); } } _ => {} @@ -473,14 +488,18 @@ pub mod rendezvous { cx: &mut Context<'_>, ) -> Poll>> { if let Some(peer_id) = self.to_dial.pop_front() { + let addr = self + .rendezvous_nodes + .iter() + .find(|node| node.peer_id == peer_id) + .map(|node| node.address.clone()) + .expect("We should have a rendezvous node for the peer id"); + + tracing::info!("Attempting to dial rendezvous peer `{peer_id}` @ `{addr}`"); + return Poll::Ready(ToSwarm::Dial { opts: DialOpts::peer_id(peer_id) - .addresses(vec![self - .rendezvous_nodes - .iter() - .find(|node| node.peer_id == peer_id) - .map(|node| node.address.clone()) - .expect("We should have a rendezvous node for the peer id")]) + .addresses(vec![addr]) .condition(PeerCondition::Disconnected) // TODO: this makes the behaviour call `NetworkBehaviour::handle_pending_outbound_connection` // but we don't implement it diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index 2b48d000e..8d6c2524e 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -1,16 +1,20 @@ use crate::asb::event_loop::EventLoopService; -use crate::protocol::Database; +use crate::protocol::alice::AliceState; +use crate::protocol::{Database, State}; use crate::{bitcoin, monero}; -use anyhow::{Context, Result}; +use ::monero::PrivateKey; +use anyhow::{anyhow, bail, Context, Result}; use jsonrpsee::server::{ServerBuilder, ServerHandle}; use jsonrpsee::types::error::ErrorCode; use jsonrpsee::types::ErrorObjectOwned; use std::sync::Arc; use swap_controller_api::{ ActiveConnectionsResponse, AsbApiServer, BitcoinBalanceResponse, BitcoinSeedResponse, - MoneroAddressResponse, MoneroBalanceResponse, MoneroSeedResponse, MultiaddressesResponse, Swap, + CooperativeRedeemResponse, MoneroAddressResponse, MoneroBalanceResponse, MoneroSeedResponse, + MultiaddressesResponse, Swap, }; use tokio_util::task::AbortOnDropHandle; +use uuid::Uuid; pub struct RpcServer { handle: ServerHandle, @@ -158,6 +162,46 @@ impl AsbApiServer for RpcImpl { Ok(swaps) } + + async fn cooperative_redeem_info( + &self, + swap_id: Uuid, + ) -> Result, ErrorObjectOwned> { + let states = self.db.get_states(swap_id).await.into_json_rpc_result()?; + + if states.is_empty() { + return Ok(None); + } + + // Check if we previously entered BtcPunished (only happens after tx_punish is confirmed) + let bob_was_punished: bool = states + .iter() + .any(|state| matches!(state, State::Alice(AliceState::BtcPunished { .. }))); + if !bob_was_punished { + anyhow!( + "Revealing cooperative redeem key would be insecure because we didn't punish yet" + ) + .into_json_rpc_error(); + } + + states + .into_iter() + .find_map(|state| match state { + // Todo: maybe also allow XmrLockTransactionSent + State::Alice(AliceState::XmrLocked { + transfer_proof, + state3, + .. + }) => Some(Some(CooperativeRedeemResponse { + s_a: PrivateKey::from_scalar(state3.s_a), + lock_tx_id: transfer_proof.tx_hash().to_string(), + lock_tx_key: transfer_proof.tx_key(), + })), + _ => None, + }) + .context("swap not cooperatively redeemable because we didn't lock the Monero") + .into_json_rpc_result() + } } trait IntoJsonRpcResult { diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index f94fbbb8a..c683288ba 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -441,7 +441,7 @@ impl ContextBuilder { }; // Create a daemon struct for the monero wallet based on the node address - let daemon = monero_sys::Daemon::try_from(monero_node_address)?; + let daemon = monero_sys::Daemon::try_from(&monero_node_address)?; // Prompt the user to open/create a Monero wallet let (wallet, seed) = open_monero_wallet( @@ -662,12 +662,12 @@ impl Context { let pool_url: String = server_info.clone().into(); tracing::info!("Switching to Monero RPC pool: {}", pool_url); - monero_sys::Daemon::try_from(pool_url)? + monero_sys::Daemon::try_from(&pool_url)? } MoneroNodeConfig::SingleNode { url } => { tracing::info!("Switching to single Monero node: {}", url); - monero_sys::Daemon::try_from(url.clone())? + monero_sys::Daemon::try_from(&url)? } }; diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 8cb5fb012..c5db6c5c5 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -10,7 +10,7 @@ use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus}; use crate::common::{get_logs, redact}; use crate::libp2p_ext::MultiAddrExt; use crate::monero::wallet_rpc::MoneroDaemon; -use crate::monero::MoneroAddressPool; +use crate::monero::{MoneroAddressPool, TransferProof, TxHash}; use crate::network::quote::BidQuote; use crate::network::rendezvous::XmrBtcNamespace; use crate::network::swarm; @@ -135,6 +135,29 @@ impl Request for CancelAndRefundArgs { } } +#[typeshare] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ManualCooperativeRedeemArgs { + pub swap_id: Uuid, + #[serde(with = "swap_serde::monero::private_key")] + pub s_a: monero::PrivateKey, + pub lock_tx_id: String, + #[serde(with = "swap_serde::monero::private_key")] + pub lock_tx_key: monero::PrivateKey, +} + +#[typeshare] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ManualCooperativeRedeemResponse; + +impl Request for ManualCooperativeRedeemArgs { + type Response = ManualCooperativeRedeemResponse; + + async fn request(self, ctx: Arc) -> Result { + resume_with_cooperative_redeem(self, ctx).await + } +} + // MoneroRecovery #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -1160,6 +1183,62 @@ pub async fn buy_xmr( Ok(BuyXmrResponse { swap_id, quote }) } +#[tracing::instrument(fields(method = "resume_with_cooperative_redeem"), skip(context))] +pub async fn resume_with_cooperative_redeem( + args: ManualCooperativeRedeemArgs, + context: Arc, +) -> Result { + // Get the current swap state + let swap_state = context + .db + .get_state(args.swap_id) + .await + .context("no swap with this id found")?; + + // Abort if not in BtcPunished + let State::Bob(BobState::BtcPunished { state, .. }) = swap_state else { + // Todo: maybe allow outside BtcPunished + bail!("Bitcoin wasn't punished - can't cooperatively redeem"); + }; + + // Attempt to acquire the swap lock -- the swap shouldn't be running if we are punished + // and already tried (and failed) the automated cooperative redeem attempt + context + .swap_lock + .acquire_swap_lock(args.swap_id) + .await + .context("Couldn't acquire swap lock")?; + + // Checking the key we received for validity, then advance and save the state + let state5 = state + .attempt_cooperative_redeem( + args.s_a.scalar, + TransferProof::new(TxHash(args.lock_tx_id), args.lock_tx_key), + ) + .context("couldn't cooperatively redeem monero")?; + let new_state = State::Bob(BobState::BtcRedeemed(state5)); + + context + .db + .insert_latest_state(args.swap_id, new_state) + .await + .context("couldn't save new state to db")?; + + tracing::info!("Validated cooperative redeem key, resuming swap"); + + // Resume the swap (next step: redeem Monero) + resume_swap( + ResumeSwapArgs { + swap_id: args.swap_id, + }, + context, + ) + .await + .context("Couldn't resume the swap after manually importing cooperative redeem key")?; + + Ok(ManualCooperativeRedeemResponse) +} + #[tracing::instrument(fields(method = "resume_swap"), skip(context))] pub async fn resume_swap( resume: ResumeSwapArgs, diff --git a/swap/src/monero.rs b/swap/src/monero.rs index 60086a9c7..e96f9db06 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -4,6 +4,7 @@ pub mod wallet_rpc; pub use ::monero::network::Network; pub use ::monero::{Address, PrivateKey, PublicKey}; pub use curve25519_dalek::scalar::Scalar; +use monero_oxide::primitives::keccak256; pub use wallet::{Daemon, Wallet, Wallets, WatchRequest}; use crate::bitcoin; @@ -60,6 +61,18 @@ impl PrivateViewKey { Self(private_key) } + /// Construct the private view key corresponding to a private spend key. + pub fn from_spend_key(spend_key: PrivateKey) -> Self { + let bytes = spend_key.scalar.to_bytes(); + + // See zero to monero: + // - page 34/85 annotation 1 + // - page 21/85 annotation 5 + let scalar = Scalar::from_bytes_mod_order(keccak256(bytes)); + + Self(PrivateKey::from_scalar(scalar)) + } + pub fn public(&self) -> PublicViewKey { PublicViewKey(PublicKey::from_private_key(&self.0)) } @@ -666,10 +679,12 @@ mod tests { use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] - pub struct MoneroPrivateKey(#[serde(with = "monero_private_key")] crate::monero::PrivateKey); + pub struct MoneroPrivateKey( + #[serde(with = "swap_serde::monero::private_key")] crate::monero::PrivateKey, + ); #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] - pub struct MoneroAmount(#[serde(with = "monero_amount")] crate::monero::Amount); + pub struct MoneroAmount(crate::monero::Amount); #[test] fn serde_monero_private_key_json() { diff --git a/swap/src/protocol/bob/state.rs b/swap/src/protocol/bob/state.rs index 94cbe6952..3e57f5575 100644 --- a/swap/src/protocol/bob/state.rs +++ b/swap/src/protocol/bob/state.rs @@ -4,10 +4,11 @@ use crate::bitcoin::{ TxLock, Txid, Wallet, }; use crate::monero::wallet::WatchRequest; -use crate::monero::TransferProof; use crate::monero::{self, MoneroAddressPool, TxHash}; +use crate::monero::{PrivateViewKey, TransferProof}; use crate::monero_ext::ScalarExt; use crate::protocol::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM}; +use ::monero::{PrivateKey, PublicKey}; use anyhow::{anyhow, bail, Context, Result}; use ecdsa_fun::adaptor::{Adaptor, HashTranscript}; use ecdsa_fun::nonce::Deterministic; @@ -87,6 +88,7 @@ impl fmt::Display for BobState { BobState::BtcRefunded(..) => write!(f, "btc is refunded"), BobState::XmrRedeemed { .. } => write!(f, "xmr is redeemed"), BobState::BtcPunished { .. } => write!(f, "btc is punished"), + BobState::BtcEarlyRefunded { .. } => write!(f, "btc is early refunded"), BobState::SafelyAborted => write!(f, "safely aborted"), } @@ -483,6 +485,7 @@ impl State3 { b: self.b, s_b: self.s_b, S_a_bitcoin: self.S_a_bitcoin, + S_a_monero: self.S_a_monero, v: self.v, xmr: self.xmr, cancel_timelock: self.cancel_timelock, @@ -505,6 +508,7 @@ impl State3 { A: self.A, b: self.b.clone(), s_b: self.s_b, + S_a_monero: self.S_a_monero, v: self.v, monero_wallet_restore_blockheight, cancel_timelock: self.cancel_timelock, @@ -570,6 +574,7 @@ pub struct State4 { b: bitcoin::SecretKey, s_b: monero::Scalar, S_a_bitcoin: bitcoin::PublicKey, + S_a_monero: monero::PublicKey, v: monero::PrivateViewKey, xmr: monero::Amount, pub cancel_timelock: CancelTimelock, @@ -672,6 +677,7 @@ impl State4 { State6 { A: self.A, b: self.b, + S_a_monero: self.S_a_monero, s_b: self.s_b, v: self.v, monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight, @@ -781,6 +787,7 @@ pub struct State6 { A: bitcoin::PublicKey, b: bitcoin::SecretKey, s_b: monero::Scalar, + S_a_monero: monero::PublicKey, v: monero::PrivateViewKey, pub xmr: monero::Amount, pub monero_wallet_restore_blockheight: BlockHeight, @@ -906,18 +913,24 @@ impl State6 { &self, s_a: monero::Scalar, lock_transfer_proof: TransferProof, - ) -> State5 { - let s_a = monero::PrivateKey::from_scalar(s_a); + ) -> Result { + let alleged_s_a = monero::PrivateKey::from_scalar(s_a); + let alleged_S_a = PublicKey::from_private_key(&alleged_s_a); + + // Make sure we've got the correct key by checking that it matches the pubkey + if alleged_S_a != self.S_a_monero { + bail!("received bogus cooperative redeem key - doesn't match view key") + } - State5 { - s_a, + Ok(State5 { + s_a: alleged_s_a, s_b: self.s_b, v: self.v, xmr: self.xmr, tx_lock: self.tx_lock.clone(), monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight, lock_transfer_proof, - } + }) } pub async fn check_for_tx_early_refund( diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index 92bdca981..7b47e316a 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -601,14 +601,16 @@ async fn next_state( ), ); - // Wait for the 10 confirmations to complete - watch_future + // Wait for the 10 confirmations to complete - if that fails we try to redeem anyway + let _ = watch_future .await - .map_err(|e| anyhow::anyhow!("Failed to wait for XMR confirmations: {}", e))?; + .inspect_err(|e| tracing::warn!(error=%e, "Failed to wait for XMR confirmations - attempting to redeem anyway")); event_emitter .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::RedeemingMonero); + tracing::info!("Monero lock transaction unlocked, redeeming the funds"); + let xmr_redeem_txids = retry( "Redeeming Monero", || async { @@ -790,7 +792,7 @@ async fn next_state( BobState::BtcRefunded(state) } - BobState::BtcPunished { state, tx_lock_id } => { + BobState::BtcPunished { state, .. } => { tracing::info!("You have been punished for not refunding in time"); event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcPunished); event_emitter.emit_swap_progress_event( @@ -807,86 +809,15 @@ async fn next_state( lock_transfer_proof, .. }) => { + let state5 = state + .attempt_cooperative_redeem(s_a, lock_transfer_proof) + .context("Can't cooperatively redeem Monero")?; + tracing::info!( - "Alice has accepted our request to cooperatively redeem the XMR" + "Alice has accepted our request to cooperatively redeem the XMR and sent the correct key" ); - let state5 = state.attempt_cooperative_redeem(s_a, lock_transfer_proof); - - let watch_request = state5.lock_xmr_watch_request_for_sweep(); - let event_emitter_clone = event_emitter.clone(); - let state5_clone = state5.clone(); - - // Wait for XMR confirmations before redeeming - monero_wallet - .wait_until_confirmed( - watch_request, - Some( - move |( - xmr_lock_tx_confirmations, - xmr_lock_tx_target_confirmations, - )| { - let event_emitter = event_emitter_clone.clone(); - let tx_hash = state5_clone.lock_transfer_proof.tx_hash(); - - event_emitter.emit_swap_progress_event( - swap_id, - TauriSwapProgressEvent::WaitingForXmrConfirmationsBeforeRedeem { - xmr_lock_txid: tx_hash, - xmr_lock_tx_confirmations, - xmr_lock_tx_target_confirmations, - }, - ); - }, - ), - ) - .await - .map_err(|e| { - anyhow::anyhow!( - "Failed to wait for XMR confirmations during cooperative redeem: {}", - e - ) - })?; - - match retry( - "Redeeming Monero", - || async { - state5 - .redeem_xmr(&monero_wallet, swap_id, monero_receive_pool.clone()) - .await - .map_err(backoff::Error::transient) - }, - Duration::from_secs(2 * 60), - None, - ) - .await - .context("Failed to redeem Monero") - { - Ok(xmr_redeem_txids) => { - event_emitter.emit_swap_progress_event( - swap_id, - TauriSwapProgressEvent::XmrRedeemInMempool { - xmr_redeem_txids, - xmr_receive_pool: monero_receive_pool.clone(), - }, - ); - - return Ok(BobState::XmrRedeemed { tx_lock_id }); - } - Err(error) => { - event_emitter.emit_swap_progress_event( - swap_id, - TauriSwapProgressEvent::CooperativeRedeemRejected { - reason: error.to_string(), - }, - ); - - let err: std::result::Result<_, anyhow::Error> = - Err(error).context("Failed to redeem XMR with revealed XMR key"); - - return err; - } - } + return Ok(BobState::BtcRedeemed(state5)); } Ok(Rejected { reason, .. }) => { let err = Err(reason.clone()) diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index a45262b81..dea52d36a 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -6,6 +6,9 @@ use async_trait::async_trait; use bitcoin_harness::{BitcoindRpcApi, Client}; use futures::Future; use get_port::get_port; +use jsonrpsee::core::middleware::layer::RpcLogger; +use jsonrpsee::http_client::transport::HttpBackend; +use jsonrpsee::http_client::HttpClient; use libp2p::core::Multiaddr; use libp2p::PeerId; use monero_harness::{image, Monero}; @@ -14,6 +17,8 @@ use rust_decimal::Decimal; use std::cmp::Ordering; use std::fmt; use std::path::PathBuf; +use swap::asb::rpc::RpcServer; +use swap_controller_api::{AsbApiClient, AsbApiServer}; use std::str::FromStr; use std::sync::Arc; @@ -124,7 +129,7 @@ where .parse() .expect("failed to parse Alice's address"); - let (alice_handle, alice_swap_handle) = start_alice( + let (alice_handle, alice_swap_handle, rpc_client) = start_alice( &alice_seed, alice_db_path.clone(), alice_listen_address.clone(), @@ -175,6 +180,7 @@ where alice_monero_wallet, alice_swap_handle, alice_handle, + alice_rpc_client: rpc_client, bob_params, bob_starting_balances, bob_bitcoin_wallet, @@ -272,7 +278,7 @@ async fn start_alice( bitcoin_wallet: Arc, monero_wallet: Arc, developer_tip: TipConfig, -) -> (AliceApplicationHandle, Receiver) { +) -> (AliceApplicationHandle, Receiver, HttpClient) { if let Some(parent_dir) = db_path.parent() { ensure_directory_exists(parent_dir).unwrap(); } @@ -306,12 +312,12 @@ async fn start_alice( .unwrap(); swarm.listen_on(listen_address).unwrap(); - let (event_loop, swap_handle, _service) = asb::EventLoop::new( + let (event_loop, swap_handle, event_loop_service) = asb::EventLoop::new( swarm, env_config, - bitcoin_wallet, - monero_wallet, - db, + bitcoin_wallet.clone(), + monero_wallet.clone(), + db.clone(), FixedRate::default(), min_buy, max_buy, @@ -320,10 +326,34 @@ async fn start_alice( ) .unwrap(); + let rpc_host = "127.0.0.1".to_string(); + let rpc_port = get_port().expect("port to be available"); + let rpc_server = RpcServer::start( + rpc_host.clone(), + rpc_port, + bitcoin_wallet.clone(), + monero_wallet.clone(), + event_loop_service, + db, + ) + .await + .unwrap(); + + std::mem::forget(rpc_server.spawn()); // Dropping would abort the rpc process + + let rpc_url = format!("http://{rpc_host}:{rpc_port}"); + let rpc_client = jsonrpsee::http_client::HttpClientBuilder::default() + .build(&rpc_url) + .expect("rpc client to be built"); + let peer_id = event_loop.peer_id(); let handle = tokio::spawn(event_loop.run()); - (AliceApplicationHandle { handle, peer_id }, swap_handle) + ( + AliceApplicationHandle { handle, peer_id }, + swap_handle, + rpc_client, + ) } #[allow(clippy::too_many_arguments)] @@ -640,6 +670,7 @@ pub struct TestContext { alice_monero_wallet: Arc, alice_swap_handle: mpsc::Receiver, alice_handle: AliceApplicationHandle, + pub alice_rpc_client: HttpClient, pub bob_params: BobParams, bob_starting_balances: StartingBalances, @@ -667,7 +698,7 @@ impl TestContext { pub async fn restart_alice(&mut self) { self.alice_handle.abort(); - let (alice_handle, alice_swap_handle) = start_alice( + let (alice_handle, alice_swap_handle, rpc_client) = start_alice( &self.alice_seed, self.alice_db_path.clone(), self.alice_listen_address.clone(), @@ -678,6 +709,7 @@ impl TestContext { ) .await; + self.alice_rpc_client = rpc_client; self.alice_handle = alice_handle; self.alice_swap_handle = alice_swap_handle; } @@ -1192,6 +1224,10 @@ pub mod bob_run_until { pub fn is_encsig_sent(state: &BobState) -> bool { matches!(state, BobState::EncSigSent(..)) } + + pub fn is_btc_punished(state: &BobState) -> bool { + matches!(state, BobState::BtcPunished { .. }) + } } pub struct SlowCancelConfig; diff --git a/swap/tests/manual_cooperative_redeem.rs b/swap/tests/manual_cooperative_redeem.rs new file mode 100644 index 000000000..a4323ec8a --- /dev/null +++ b/swap/tests/manual_cooperative_redeem.rs @@ -0,0 +1,75 @@ +pub mod harness; + +use anyhow::Context; +use harness::FastPunishConfig; +use swap::asb::FixedRate; +use swap::monero::{TransferProof, TxHash}; +use swap::protocol::bob::BobState; +use swap::protocol::{alice, bob, State}; +use swap_controller_api::AsbApiClient; + +use crate::harness::bob_run_until::{is_btc_locked, is_btc_punished}; +use crate::harness::TestContext; + +/// Bob locks Btc and Alice locks Xmr. Bob does not act; he fails to send Alice +/// the encsig and fail to refund or redeem. Alice punishes. Bob then cooperates with Alice and redeems XMR with her key. +/// But this time, we use the manual export of the cooperative redeem key via the asb-controller. +#[tokio::test] +async fn alice_and_bob_manual_cooperative_redeem_after_punish() { + harness::setup_test(FastPunishConfig, None, |mut ctx: TestContext| async move { + let (bob_swap, bob_join_handle) = ctx.bob_swap().await; + let bob_swap_id = bob_swap.id; + let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + let bob_state = bob_swap.await??; + assert!(matches!(bob_state, BobState::BtcLocked { .. })); + + let alice_state = alice_swap.await??; + ctx.assert_alice_punished(alice_state).await; + + // Let bob realize he was punished + let (bob_swap, bob_join_handle) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) + .await; + let bob_state = tokio::spawn(bob::run_until(bob_swap, is_btc_punished)).await??; + assert!(matches!(bob_state, BobState::BtcPunished { .. })); + + // Manually do the cooperative redeem via rpc server + let manual_cooperative_redeem_info = ctx + .alice_rpc_client + .cooperative_redeem_info(bob_swap_id) + .await? + .context("swap not found")?; + let BobState::BtcPunished { state, .. } = bob_state else { + panic!("bob unexpected state") + }; + let state5 = state.attempt_cooperative_redeem( + manual_cooperative_redeem_info.s_a.scalar, + TransferProof::new( + TxHash(manual_cooperative_redeem_info.lock_tx_id), + manual_cooperative_redeem_info.lock_tx_key, + ), + )?; + let new_state = State::Bob(BobState::BtcRedeemed(state5)); + // Insert new state (BtcRedeemed but we got the key manually) + ctx.bob_swap() + .await + .0 + .db + .insert_latest_state(bob_swap_id, new_state) + .await?; + + // Now try to have bob finish the swap normally + let (bob_swap, _) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) + .await; + + let bob_state = bob::run(bob_swap).await?; + ctx.assert_bob_redeemed(bob_state).await; + Ok(()) + }) + .await; +} diff --git a/swap/tests/manual_cooperative_redeem_malicious_key.rs b/swap/tests/manual_cooperative_redeem_malicious_key.rs new file mode 100644 index 000000000..b9d13fa1b --- /dev/null +++ b/swap/tests/manual_cooperative_redeem_malicious_key.rs @@ -0,0 +1,67 @@ +pub mod harness; + +use anyhow::Context; +use harness::FastPunishConfig; +use swap::asb::FixedRate; +use swap::monero::{TransferProof, TxHash}; +use swap::protocol::bob::BobState; +use swap::protocol::{alice, bob, State}; +use swap_controller_api::AsbApiClient; + +use crate::harness::bob_run_until::{is_btc_locked, is_btc_punished}; +use crate::harness::TestContext; + +/// Bob locks Btc and Alice locks Xmr. Bob does not act; he fails to send Alice +/// the encsig and fail to refund or redeem. Alice punishes. Bob then cooperates with Alice and redeems XMR with her key. +/// But this time, we use the manual export of the cooperative redeem key via the asb-controller. +/// And also, alice sends a malicious key! So we expect the cooperative redeem check to fail before changing states. +#[tokio::test] +async fn bob_rejects_malicious_cooperative_redeem_key() { + harness::setup_test(FastPunishConfig, None, |mut ctx: TestContext| async move { + let (bob_swap, bob_join_handle) = ctx.bob_swap().await; + let bob_swap_id = bob_swap.id; + let bob_swap = tokio::spawn(bob::run_until(bob_swap, is_btc_locked)); + + let alice_swap = ctx.alice_next_swap().await; + let alice_swap = tokio::spawn(alice::run(alice_swap, FixedRate::default())); + + let bob_state = bob_swap.await??; + assert!(matches!(bob_state, BobState::BtcLocked { .. })); + + let alice_state = alice_swap.await??; + ctx.assert_alice_punished(alice_state).await; + + // Let bob realize he was punished + let (bob_swap, bob_join_handle) = ctx + .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) + .await; + let bob_state = tokio::spawn(bob::run_until(bob_swap, is_btc_punished)).await??; + assert!(matches!(bob_state, BobState::BtcPunished { .. })); + + // Manually do the cooperative redeem via rpc server + let mut manual_cooperative_redeem_info = ctx + .alice_rpc_client + .cooperative_redeem_info(bob_swap_id) + .await? + .context("swap not found")?; + let BobState::BtcPunished { state, .. } = bob_state else { + panic!("bob unexpected state") + }; + // Malicous: alice doesn't give the correct secret key + manual_cooperative_redeem_info.s_a.scalar = + manual_cooperative_redeem_info.s_a.scalar.invert(); + let state5 = state.attempt_cooperative_redeem( + manual_cooperative_redeem_info.s_a.scalar, + TransferProof::new( + TxHash(manual_cooperative_redeem_info.lock_tx_id), + manual_cooperative_redeem_info.lock_tx_key, + ), + ); + assert!( + state5.is_err(), + "cooperative redeem key doesn't match actual secret key - the check should fail" + ); + Ok(()) + }) + .await; +} diff --git a/throttle/src/throttle.rs b/throttle/src/throttle.rs index c092d643c..f1b559577 100644 --- a/throttle/src/throttle.rs +++ b/throttle/src/throttle.rs @@ -2,12 +2,12 @@ // MIT License use std::pin::Pin; -use std::sync::{mpsc, Arc, Mutex}; +use std::sync::{Arc, Mutex, mpsc}; use std::time::{self, /* SystemTime, UNIX_EPOCH, */ Duration}; pub fn throttle(closure: F, delay: Duration) -> Throttle where - F: Fn(T) -> () + Send + Sync + 'static, + F: Fn(T) + Send + Sync + 'static, T: Send + Sync + 'static, { let (sender, receiver) = mpsc::channel(); @@ -18,7 +18,7 @@ where })); let dup_throttle_config = throttle_config.clone(); - let throttle = Throttle { + Throttle { sender: Some(sender), thread: Some(std::thread::spawn(move || { let throttle_config = dup_throttle_config; @@ -51,7 +51,7 @@ where } } } else { - let message = receiver.recv_timeout((*throttle_config.lock().unwrap()).delay); + let message = receiver.recv_timeout((throttle_config.lock().unwrap()).delay); let now = time::Instant::now(); match message { Ok(param) => { @@ -90,12 +90,11 @@ where } })), throttle_config, - }; - throttle + } } struct ThrottleConfig { - closure: Pin () + Send + Sync + 'static>>, + closure: Pin>, delay: Duration, } impl Drop for ThrottleConfig {