diff --git a/.gitignore b/.gitignore index cd376b2ed4..6b74addbc6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ tempdb.sqlite swap-orchestrator/docker-compose.yml swap-orchestrator/config.toml +# ASB RPC auth keyfile (secret salt:hmac verifier) +rpc-auth + # release build generator scripts release-build.sh cn_macos diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b6c79e70..2c0200a845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ASB+CONTROLLER: The JSON-RPC server now requires authentication. The ASB verifies a password against a hashed `salt:hmac` keyfile (`--rpc-auth-file`), and `asb-controller` prompts for the password on startup. Generate the keyfile with `orchestrator gen-rpc-auth`. + ## [4.8.4] - 2026-06-09 ## [4.8.3] - 2026-06-08 diff --git a/Cargo.lock b/Cargo.lock index d1cfdd81ee..15a1ad3ee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10668,6 +10668,8 @@ dependencies = [ "tor-hsservice", "tor-llcrypto", "tor-rtcompat", + "tower", + "tower-http", "tracing", "tracing-appender", "tracing-ext", @@ -10719,6 +10721,7 @@ dependencies = [ "bitcoin 0.32.8", "clap 4.6.0", "comfy-table", + "dialoguer", "jsonrpsee", "monero-oxide-ext", "rustyline", @@ -10794,10 +10797,14 @@ dependencies = [ "config", "console 0.16.3", "dialoguer", + "hex", + "hmac", "libp2p", "monero-address", + "rand 0.8.5", "rust_decimal", "serde", + "sha2", "swap-fs", "swap-serde", "thiserror 1.0.69", @@ -13098,6 +13105,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "iri-string", + "mime", "pin-project-lite", "tower", "tower-layer", diff --git a/justfile b/justfile index 337875d749..118c36f112 100644 --- a/justfile +++ b/justfile @@ -85,9 +85,13 @@ test_monero_sys: swap: cargo build -p swap-asb --bin asb && cd swap && cargo build --bin=swap +# Generate the ASB RPC auth keyfile +gen-rpc-auth: + cargo run -p swap-orchestrator --bin orchestrator -- gen-rpc-auth + # Run the asb on testnet asb-testnet: - cargo run -p swap-asb --bin asb -- --testnet --trace start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 + cargo run -p swap-asb --bin asb -- --testnet --trace start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 --rpc-auth-file rpc-auth # Launch the ASB controller REPL against a local testnet ASB instance asb-testnet-controller: diff --git a/swap-asb/src/command.rs b/swap-asb/src/command.rs index e3999675a7..601da11220 100644 --- a/swap-asb/src/command.rs +++ b/swap-asb/src/command.rs @@ -30,6 +30,7 @@ where resume_only, rpc_bind_host, rpc_bind_port, + rpc_auth_file, } => { // Validate RPC bind arguments early validate_rpc_bind_args(&rpc_bind_host, &rpc_bind_port)?; @@ -44,6 +45,7 @@ where resume_only, rpc_bind_host, rpc_bind_port, + rpc_auth_file, }, } } @@ -226,6 +228,7 @@ pub enum Command { resume_only: bool, rpc_bind_host: Option, rpc_bind_port: Option, + rpc_auth_file: Option, }, History { only_unfinished: bool, @@ -319,6 +322,11 @@ pub enum RawCommand { help = "Port to bind the JSON-RPC server to (e.g., 9944). Must be used together with --rpc-bind-host." )] rpc_bind_port: Option, + #[structopt( + long = "rpc-auth-file", + help = "Path to a file containing the `salt:hmac` RPC auth verifier. Required when the JSON-RPC server is enabled." + )] + rpc_auth_file: Option, }, #[structopt(about = "Prints all logging messages issued in the past.")] Logs { @@ -494,6 +502,7 @@ mod tests { resume_only: false, rpc_bind_host: None, rpc_bind_port: None, + rpc_auth_file: None, }, }; let args = parse_args(raw_ars).unwrap(); @@ -707,6 +716,7 @@ mod tests { resume_only: false, rpc_bind_host: None, rpc_bind_port: None, + rpc_auth_file: None, }, }; let args = parse_args(raw_ars).unwrap(); @@ -948,6 +958,7 @@ mod tests { resume_only: false, rpc_bind_host: None, rpc_bind_port: None, + rpc_auth_file: None, }, }; let args = parse_args(raw_ars).unwrap(); diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index 5450533800..14277e6340 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -157,6 +157,7 @@ pub async fn main() -> Result<()> { resume_only, rpc_bind_host, rpc_bind_port, + rpc_auth_file, } => { let db = open_db(db_file, AccessMode::ReadWrite, None).await?; @@ -388,9 +389,26 @@ pub async fn main() -> Result<()> { // Start RPC server conditionally let _rpc_server = if let (Some(host), Some(port)) = (rpc_bind_host, rpc_bind_port) { + let auth_file = rpc_auth_file.context( + "The JSON-RPC server requires authentication: pass --rpc-auth-file pointing at a `salt:hmac` verifier file", + )?; + let auth_verifier = std::fs::read_to_string(&auth_file) + .with_context(|| { + format!("Failed to read RPC auth file at {}", auth_file.display()) + })? + .trim() + .to_string(); + if !auth_verifier.contains(':') { + anyhow::bail!( + "RPC auth file at {} is malformed: expected `salt:hmac`", + auth_file.display() + ); + } + let rpc_server = RpcServer::start( host, port, + Some(auth_verifier), bitcoin_wallet.clone(), monero_wallet.clone(), event_loop_service, diff --git a/swap-controller/Cargo.toml b/swap-controller/Cargo.toml index 1009f99935..dde35d1553 100644 --- a/swap-controller/Cargo.toml +++ b/swap-controller/Cargo.toml @@ -12,6 +12,7 @@ anyhow = { workspace = true } bitcoin = { workspace = true } clap = { version = "4", features = ["derive"] } comfy-table = "7.2.1" +dialoguer = { workspace = true } jsonrpsee = { workspace = true, features = ["client-core", "http-client"] } monero-oxide-ext = { path = "../monero-oxide-ext" } rustyline = "17.0.0" diff --git a/swap-controller/src/main.rs b/swap-controller/src/main.rs index 108015a529..38fb4a65d3 100644 --- a/swap-controller/src/main.rs +++ b/swap-controller/src/main.rs @@ -1,15 +1,17 @@ mod cli; mod repl; +use anyhow::Context; use clap::Parser; use cli::{Cli, Cmd}; +use jsonrpsee::http_client::{HeaderMap, HeaderValue, HttpClient, HttpClientBuilder}; use swap_controller_api::{AsbApiClient, MoneroSeedResponse}; #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - let client = jsonrpsee::http_client::HttpClientBuilder::default().build(&cli.url)?; + let client = authenticate(&cli.url).await?; match cli.cmd { None => repl::run(client, dispatch).await?, @@ -23,6 +25,46 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Prompts for the RPC password and returns a client once the server accepts +/// it, re-prompting on an authentication failure and bailing if the server is +/// unreachable for any other reason. +async fn authenticate(url: &str) -> anyhow::Result { + loop { + let password = dialoguer::Password::new() + .with_prompt("ASB RPC password") + .interact() + .context("Failed to read password")?; + + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {password}")) + .context("Password is not a valid HTTP header value")?, + ); + let client = HttpClientBuilder::default() + .set_headers(headers) + .build(url)?; + + match client.check_connection().await { + Ok(()) => return Ok(client), + Err(e) if is_auth_failure(&e) => eprintln!("Authentication failed, try again."), + Err(e) => return Err(e).context("Failed to reach the ASB RPC server"), + } + } +} + +fn is_auth_failure(error: &jsonrpsee::core::ClientError) -> bool { + use jsonrpsee::http_client::transport::Error as TransportError; + + let jsonrpsee::core::ClientError::Transport(source) = error else { + return false; + }; + matches!( + source.downcast_ref::(), + Some(TransportError::Rejected { status_code: 401 }) + ) +} + async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { match cmd { Cmd::CheckConnection => { diff --git a/swap-env/Cargo.toml b/swap-env/Cargo.toml index cbaa67850d..084176a989 100644 --- a/swap-env/Cargo.toml +++ b/swap-env/Cargo.toml @@ -9,10 +9,14 @@ bitcoin = { workspace = true } config = { version = "0.14", default-features = false, features = ["toml"] } console = { workspace = true } dialoguer = { workspace = true } +hex = "0.4" +hmac = "0.12" libp2p = { workspace = true, features = ["serde"] } monero-address = { workspace = true } +rand = { workspace = true } rust_decimal = { workspace = true } serde = { workspace = true } +sha2 = { workspace = true } swap-fs = { path = "../swap-fs" } swap-serde = { path = "../swap-serde" } thiserror = { workspace = true } diff --git a/swap-env/src/lib.rs b/swap-env/src/lib.rs index fa2447bccb..57b85d6208 100644 --- a/swap-env/src/lib.rs +++ b/swap-env/src/lib.rs @@ -2,3 +2,4 @@ pub mod config; pub mod defaults; pub mod env; pub mod prompt; +pub mod rpc_auth; diff --git a/swap-env/src/rpc_auth.rs b/swap-env/src/rpc_auth.rs new file mode 100644 index 0000000000..ec9bb21352 --- /dev/null +++ b/swap-env/src/rpc_auth.rs @@ -0,0 +1,106 @@ +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +const SALT_BYTES: usize = 16; +const MIN_PASSWORD_LENGTH: usize = 16; + +/// Builds a `:` verifier for the password using a fresh random salt. +pub fn generate(password: &str) -> String { + let mut salt = [0u8; SALT_BYTES]; + rand::thread_rng().fill_bytes(&mut salt); + let salt = hex::encode(salt); + let hmac = hash_with_salt(password, &salt); + format!("{salt}:{hmac}") +} + +/// Constant-time check of a password against a `:` verifier. +/// A malformed verifier never authenticates. +pub fn verify(password: &str, verifier: &str) -> bool { + let Some((salt, expected)) = verifier.split_once(':') else { + return false; + }; + let Ok(expected) = hex::decode(expected) else { + return false; + }; + + let mut mac = HmacSha256::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length"); + mac.update(password.as_bytes()); + mac.verify_slice(&expected).is_ok() +} + +fn hash_with_salt(password: &str, salt: &str) -> String { + let mut mac = HmacSha256::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length"); + mac.update(password.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// Rejects weak passwords. Requires length and a mix of character classes, +/// and forbids whitespace to keep the value unambiguous in an HTTP header. +pub fn validate_password_strength(password: &str) -> Result<(), String> { + let mut missing = Vec::new(); + + if password.chars().count() < MIN_PASSWORD_LENGTH { + missing.push(format!("at least {MIN_PASSWORD_LENGTH} characters")); + } + if !password.chars().any(|c| c.is_ascii_lowercase()) { + missing.push("a lowercase letter".to_string()); + } + if !password.chars().any(|c| c.is_ascii_uppercase()) { + missing.push("an uppercase letter".to_string()); + } + if !password.chars().any(|c| c.is_ascii_digit()) { + missing.push("a digit".to_string()); + } + if !password + .chars() + .any(|c| !c.is_ascii_alphanumeric() && !c.is_whitespace()) + { + missing.push("a special character".to_string()); + } + + if password.chars().any(char::is_whitespace) { + return Err("Password must not contain whitespace".to_string()); + } + if missing.is_empty() { + return Ok(()); + } + + Err(format!("Password is too weak; it must have {}", missing.join(", "))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_then_verify_roundtrips() { + let verifier = generate("Str0ng!Passphrase42xx"); + assert!(verify("Str0ng!Passphrase42xx", &verifier)); + assert!(!verify("not the password", &verifier)); + } + + #[test] + fn verify_is_reproducible_for_a_fixed_salt() { + let verifier = format!("deadbeef:{}", hash_with_salt("hunter2", "deadbeef")); + assert!(verify("hunter2", &verifier)); + assert!(!verify("hunter3", &verifier)); + } + + #[test] + fn malformed_verifiers_never_authenticate() { + assert!(!verify("pw", "missing-colon")); + assert!(!verify("pw", "salt:not-hex")); + assert!(!verify("pw", "")); + } + + #[test] + fn strength_rejects_weak_and_accepts_strong() { + assert!(validate_password_strength("Sh0rt!").is_err()); + assert!(validate_password_strength("alllowercaseletters").is_err()); + assert!(validate_password_strength("has a space In It 9!").is_err()); + assert!(validate_password_strength("Str0ng!Passphrase42xx").is_ok()); + } +} diff --git a/swap-orchestrator/README.md b/swap-orchestrator/README.md index 7d48d3862f..87c0c088a7 100644 --- a/swap-orchestrator/README.md +++ b/swap-orchestrator/README.md @@ -56,6 +56,18 @@ To build the images, run this command. Also run this after upgrading the `orches docker compose build --no-cache # --no-cache fixes a git caching issue (error: tag clobbered) ``` +### Set the JSON-RPC password + +The `asb` authenticates its JSON-RPC endpoint, and refuses to start it without an auth keyfile. Before starting the environment, generate one: + +```bash +./orchestrator gen-rpc-auth +``` + +This prompts for a strong password, writes its salted HMAC-SHA256 verifier to `./rpc-auth` (mounted read-only into the `asb` container), and never stores the password itself. Keep the password — you enter it when attaching to `asb-controller`. + +To expose the RPC endpoint publicly (e.g. for a remote `asb-controller`), add an ingress rule to your existing Cloudflare tunnel pointing a hostname — on any Cloudflare-managed domain — at `http://asb:9944`. The password protects it, so no separate tunnel is required. + To start the environment, run a command [such as](https://docs.docker.com/reference/cli/docker/compose/up/): ```bash @@ -77,11 +89,13 @@ To view high-verbosity logs of the asb, peek inside the `asb-tracing-logger` con docker compose logs -f --tail 100 asb-tracing-logger ``` -Once the `asb` is running properly you can get a shell +Once the `asb` is running properly you can get a shell. It prompts for the RPC password you set with `gen-rpc-auth` before granting access. ```bash $ docker compose attach asb-controller +ASB RPC password: ******** + ASB Control Shell - Type 'help' for commands, 'quit' to exit asb> help diff --git a/swap-orchestrator/src/compose.rs b/swap-orchestrator/src/compose.rs index abf6254fa0..f1a4ba6766 100644 --- a/swap-orchestrator/src/compose.rs +++ b/swap-orchestrator/src/compose.rs @@ -15,6 +15,8 @@ pub const DOCKER_LOG_MAX_FILE: &str = "5"; pub const ASB_DATA_DIR: &str = "/asb-data"; pub const ASB_CONFIG_FILE: &str = "config.toml"; +pub const ASB_RPC_AUTH_FILE: &str = "rpc-auth"; +pub const ASB_RPC_AUTH_FILE_ON_HOST: &str = "./rpc-auth"; pub const DOCKER_COMPOSE_FILE: &str = "./docker-compose.yml"; pub const PROMTAIL_CONFIG_FILE: &str = "./promtail.yml"; pub const PROMETHEUS_CONFIG_FILE: &str = "./prometheus.yml"; @@ -177,6 +179,14 @@ impl OrchestratorDirectories { pub fn asb_config_path_on_host_as_path_buf(&self) -> PathBuf { PathBuf::from(self.asb_config_path_on_host()) } + + pub fn asb_rpc_auth_path_inside_container(&self) -> PathBuf { + self.asb_data_dir.join(ASB_RPC_AUTH_FILE) + } + + pub fn asb_rpc_auth_path_on_host(&self) -> &'static str { + ASB_RPC_AUTH_FILE_ON_HOST + } } /// See: https://docs.docker.com/reference/compose-file/build/#illustrative-example @@ -234,6 +244,10 @@ fn build(input: OrchestratorInput) -> String { flag!("start"), flag!("--rpc-bind-port={}", input.ports.asb_rpc_port), flag!("--rpc-bind-host=0.0.0.0"), + flag!( + "--rpc-auth-file={}", + PathBuf::from(ASB_DATA_DIR).join(ASB_RPC_AUTH_FILE).display() + ), ]; // monerod's --proxy addr:port and --tx-proxy tor,addr;port can only take numeric addr, @@ -553,6 +567,7 @@ services: - electrs volumes: - '{asb_config_path_on_host}:{asb_config_path_inside_container}' + - '{asb_rpc_auth_path_on_host}:{asb_rpc_auth_path_inside_container}:ro' - 'asb-data:{asb_data_dir}' ports: - '0.0.0.0:{asb_port}:{asb_port}' @@ -620,6 +635,8 @@ volumes: asb_data_dir = input.directories.asb_data_dir.display(), asb_config_path_on_host = input.directories.asb_config_path_on_host(), asb_config_path_inside_container = input.directories.asb_config_path_inside_container().display(), + asb_rpc_auth_path_on_host = input.directories.asb_rpc_auth_path_on_host(), + asb_rpc_auth_path_inside_container = input.directories.asb_rpc_auth_path_inside_container().display(), ); validate_compose(&compose_str); diff --git a/swap-orchestrator/src/keygen.rs b/swap-orchestrator/src/keygen.rs new file mode 100644 index 0000000000..a78f402e3e --- /dev/null +++ b/swap-orchestrator/src/keygen.rs @@ -0,0 +1,31 @@ +use crate::compose::ASB_RPC_AUTH_FILE_ON_HOST; + +/// Prompts for a strong password and writes its `salt:hmac` verifier to the +/// host keyfile the generated compose mounts into the asb container. +pub fn generate_rpc_auth_keyfile() { + let password = dialoguer::Password::new() + .with_prompt("Enter a strong RPC password") + .with_confirmation("Confirm password", "Passwords do not match") + .interact() + .expect("Failed to read password"); + + if let Err(problem) = swap_env::rpc_auth::validate_password_strength(&password) { + panic!("{problem}"); + } + + let verifier = swap_env::rpc_auth::generate(&password); + std::fs::write(ASB_RPC_AUTH_FILE_ON_HOST, &verifier).expect("Failed to write RPC auth keyfile"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + ASB_RPC_AUTH_FILE_ON_HOST, + std::fs::Permissions::from_mode(0o600), + ) + .expect("Failed to restrict permissions on RPC auth keyfile"); + } + + println!("Wrote RPC auth verifier to {ASB_RPC_AUTH_FILE_ON_HOST}"); + println!("Enter this password in asb-controller to access the RPC server."); +} diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index 812c27c594..c154bbf949 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -1,6 +1,7 @@ mod compose; mod containers; mod images; +mod keygen; mod prompt; use swap_orchestrator as _; @@ -174,6 +175,11 @@ fn read_gh_token_from_env() -> Option { } fn main() { + if std::env::args().nth(1).as_deref() == Some("gen-rpc-auth") { + keygen::generate_rpc_auth_keyfile(); + return; + } + // Cloudflare Tunnel is opt-in via env vars so existing deployments // keep working unchanged. let cloudflared_config = read_cloudflared_config_from_env(); @@ -316,7 +322,7 @@ fn main() { config_prompt::rendezvous_points().expect("Failed to prompt for rendezvous points"); let tor_hidden_service = config_prompt::tor_hidden_service().expect("Failed to prompt for tor hidden service"); - let listen_addresses = config_prompt::listen_addresses(&defaults.listen_address_tcp) + let listen_addresses = config_prompt::listen_addresses(&defaults.listen_addresses) .expect("Failed to prompt for listen addresses"); let monero_node_type = prompt::monero_node_type(); let electrum_server_type = prompt::electrum_server_type(&defaults.electrum_rpc_urls); diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 6460241288..4828e66f17 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -91,6 +91,8 @@ http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } prometheus-client = "0.22" +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["validate-request"] } # Tokio tokio = { workspace = true, features = ["process", "fs", "net", "parking_lot", "rt"] } diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index 7ed89e10cc..7d8936c359 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -3,12 +3,13 @@ use crate::monero; use crate::protocol::Database; use anyhow::{Context, Result}; use bitcoin_wallet::BitcoinWallet; -use jsonrpsee::server::{ServerBuilder, ServerHandle}; +use jsonrpsee::server::{HttpBody, HttpRequest, HttpResponse, ServerBuilder, ServerHandle}; use jsonrpsee::types::ErrorObjectOwned; use jsonrpsee::types::error::ErrorCode; use rust_decimal::prelude::ToPrimitive; use rust_decimal::{Decimal, RoundingStrategy}; use std::sync::Arc; +use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer}; use swap_controller_api::{ ActiveConnectionsResponse, AsbApiServer, BitcoinBalanceResponse, BitcoinSeedResponse, ExternalBitcoinRedeemAddressResponse, MoneroAddressResponse, MoneroBalanceResponse, @@ -29,12 +30,21 @@ impl RpcServer { pub async fn start( host: String, port: u16, + auth_verifier: Option, bitcoin_wallet: Arc, monero_wallet: Arc, event_loop_service: EventLoopService, db: Arc, ) -> Result { + let http_middleware = tower::ServiceBuilder::new() + .option_layer(auth_verifier.map(|verifier| { + ValidateRequestHeaderLayer::custom(BearerPasswordAuth { + verifier: Arc::from(verifier), + }) + })); + let server = ServerBuilder::default() + .set_http_middleware(http_middleware) .build((host, port)) .await .context("Failed to build RPC server")?; @@ -62,6 +72,31 @@ impl RpcServer { } } +#[derive(Clone)] +struct BearerPasswordAuth { + verifier: Arc, +} + +impl ValidateRequest for BearerPasswordAuth { + type ResponseBody = HttpBody; + + fn validate(&mut self, request: &mut HttpRequest) -> Result<(), HttpResponse> { + let presented = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + + match presented { + Some(password) if swap_env::rpc_auth::verify(password, &self.verifier) => Ok(()), + _ => Err(HttpResponse::builder() + .status(401) + .body(HttpBody::empty()) + .expect("static 401 response is valid")), + } + } +} + pub struct RpcImpl { bitcoin_wallet: Arc, monero_wallet: Arc, diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index 7ac1d517c1..9d82b50123 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -445,6 +445,7 @@ async fn start_alice( let rpc_server_handle = asb::rpc::RpcServer::start( "127.0.0.1".to_string(), rpc_port, + None, bitcoin_wallet, monero_wallet, service,