#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tauri::State;
/// Strict allowlist of permitted Bitcoin Core RPC methods.
/// A compromised webview CANNOT call arbitrary RPC methods.
const ALLOWED_RPC_METHODS: &[&str] = &[
"getblockcount",
"getnetworkinfo",
"getreceivedbyaddress",
"listunspent",
"estimatesmartfee",
"listreceivedbyaddress",
"sendrawtransaction",
"getrawtransaction",
"decoderawtransaction",
"createpsbt",
"signrawtransactionwithkey",
"finalizepsbt",
"getdescriptorinfo",
"deriveaddresses",
"createmultisig",
"walletcreatefundedpsbt",
"decodepsbt",
];
#[derive(Clone, Serialize, Deserialize)]
struct RpcRequest {
method: String,
params: Vec<Value>,
}
#[derive(Clone)]
struct BitcoinConfig {
url: String,
username: String,
password: String,
}
struct AppState {
bitcoin: BitcoinConfig,
http: reqwest::Client,
}
async fn do_bitcoin_rpc(
http: &reqwest::Client,
config: &BitcoinConfig,
request: &RpcRequest,
) -> Result<Value, String> {
// Enforce allowlist — reject any method not explicitly permitted
if !ALLOWED_RPC_METHODS.contains(&request.method.as_str()) {
return Err(format!(
"RPC method not allowed: {}. Only methods in the allowlist are permitted.",
request.method
));
}
let payload = serde_json::json!({
"jsonrpc": "1.0",
"id": "btc-wallet",
"method": request.method,
"params": request.params,
});
let resp = http
.post(&config.url)
.basic_auth(&config.username, Some(&config.password))
.json(&payload)
.send()
.await
.map_err(|e| format!("RPC request failed: {e}"))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse RPC response: {e}"))?;
if let Some(err) = body.get("error") {
return Err(format!("RPC error: {err:?}"));
}
Ok(body.get("result").cloned().unwrap_or(Value::Null))
}
/// Call into the Python wallet engine for crypto operations.
/// Uses an allowlist of permitted commands instead of forwarding arbitrary strings.
const ALLOWED_PYTHON_COMMANDS: &[&str] = &[
"generate_seed",
"create_wallet",
"derive_addresses",
"get_balance",
"get_block_count",
"get_network_info",
"validate_seed",
"import_wallet",
"get_utxos",
"create_psbt",
"sign_psbt",
"finalize_psbt",
"sign_and_finalize_psbt",
"broadcast",
"decode_psbt",
"get_tx_history",
"estimate_fee",
"save_wallet",
"load_wallet",
"list_wallets",
"set_label",
"get_utxo_tree",
"create_multisig_wallet",
"get_multisig_balance",
"get_multisig_utxos",
"get_multisig_addresses",
"create_multisig_psbt",
"sign_multisig_psbt",
"combine_psbt",
"finalize_multisig_psbt",
];
#[tauri::command]
async fn python_proxy(
state: State<'_, AppState>,
command: String,
args: Value,
) -> Result<Value, String> {
// Enforce allowlist — reject any command not explicitly permitted
if !ALLOWED_PYTHON_COMMANDS.contains(&command.as_str()) {
return Err(format!(
"Python command not allowed: {}. Only commands in the allowlist are permitted.",
command
));
}
let python_url = std::env::var("BTC_PYTHON_URL")
.unwrap_or_else(|_| "http://127.0.0.1:14195/api".to_string());
let endpoint = format!("{}/{}", python_url, command);
let api_key = std::env::var("WALLET_API_KEY").unwrap_or_default();
let mut builder = state
.http
.post(&endpoint)
.json(&args);
if !api_key.is_empty() {
builder = builder.header("X-API-Key", &api_key);
}
let resp = builder
.send()
.await
.map_err(|e| format!("Python proxy failed: {e}"))?;
resp.json::<Value>()
.await
.map_err(|e| format!("Failed to parse Python response: {e}"))
}
#[tauri::command]
async fn get_block_count(state: State<'_, AppState>) -> Result<i64, String> {
let result = do_bitcoin_rpc(
&state.http,
&state.bitcoin,
&RpcRequest {
method: "getblockcount".to_string(),
params: vec![],
},
)
.await?;
result.as_i64().ok_or("Invalid block count response".to_string())
}
#[tauri::command]
async fn get_network_info(state: State<'_, AppState>) -> Result<Value, String> {
do_bitcoin_rpc(
&state.http,
&state.bitcoin,
&RpcRequest {
method: "getnetworkinfo".to_string(),
params: vec![],
},
)
.await
}
#[tauri::command]
async fn get_balance(
state: State<'_, AppState>,
addresses: Vec<String>,
) -> Result<Value, String> {
let mut total = 0.0_f64;
for addr in &addresses {
let result = do_bitcoin_rpc(
&state.http,
&state.bitcoin,
&RpcRequest {
method: "getreceivedbyaddress".to_string(),
params: vec![Value::String(addr.clone()), Value::Number(serde_json::Number::from(0))],
},
).await?;
if let Some(val) = result.as_f64() {
total += val;
}
}
Ok(json!({ "balance_btc": total, "balance_sats": (total * 100_000_000.0) as u64 }))
}
#[tauri::command]
async fn get_utxos(
state: State<'_, AppState>,
addresses: Vec<String>,
) -> Result<Value, String> {
let result = do_bitcoin_rpc(
&state.http,
&state.bitcoin,
&RpcRequest {
method: "listunspent".to_string(),
params: vec![Value::Array(vec![
Value::Number(serde_json::Number::from(0)),
Value::Number(serde_json::Number::from(999999)),
Value::Array(addresses.into_iter().map(Value::String).collect()),
])],
},
)
.await?;
if let Some(arr) = result.as_array() {
Ok(json!({ "utxos": arr }))
} else {
Ok(json!({ "utxos": [] }))
}
}
#[tauri::command]
async fn estimate_fee(
state: State<'_, AppState>,
blocks: Option<u32>,
) -> Result<Value, String> {
let blocks = blocks.unwrap_or(6);
let result = do_bitcoin_rpc(
&state.http,
&state.bitcoin,
&RpcRequest {
method: "estimatesmartfee".to_string(),
params: vec![Value::Number(serde_json::Number::from(blocks))],
},
).await?;
Ok(result)
}
#[tauri::command]
async fn get_tx_history(
state: State<'_, AppState>,
address: String,
count: Option<u32>,
) -> Result<Value, String> {
let count = count.unwrap_or(20);
let result = do_bitcoin_rpc(
&state.http,
&state.bitcoin,
&RpcRequest {
method: "listreceivedbyaddress".to_string(),
params: vec![
Value::Number(serde_json::Number::from(0)),
Value::Bool(true),
Value::Array(vec![Value::String(address)]),
],
},
).await?;
// Filter to most recent `count`
if let Some(arr) = result.as_array() {
let filtered: Vec<&Value> = arr.iter().take(count as usize).collect();
Ok(json!(filtered))
} else {
Ok(json!([]))
}
}
fn main() {
let rpc_url = std::env::var("BTC_RPC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:8332".to_string());
let rpc_user = std::env::var("BTC_RPC_USER").unwrap_or_default();
let rpc_pass = std::env::var("BTC_RPC_PASS").unwrap_or_default();
if rpc_user.is_empty() || rpc_pass.is_empty() {
eprintln!(
"Error: BTC_RPC_USER and BTC_RPC_PASS environment variables are required.\n\
Set them before running the application."
);
std::process::exit(1);
}
let config = BitcoinConfig {
url: rpc_url,
username: rpc_user,
password: rpc_pass,
};
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client");
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
// NOTE: tauri_plugin_shell is loaded but disabled via capabilities.
// Do not grant shell access in capabilities unless specifically needed.
.manage(AppState { bitcoin: config, http })
.invoke_handler(tauri::generate_handler![
python_proxy,
get_block_count,
get_network_info,
get_balance,
get_utxos,
estimate_fee,
get_tx_history,
])
.run(tauri::generate_context!())
.expect("failed to run BTC Wallet");
}