pub mod actions;
pub use actions::BitcoinClientProtocol;
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{error, info};
use crate::client::bitcoin::actions::BITCOIN_CLIENT_RESPONSE_RECEIVED_EVENT;
use crate::client::http_fetch::FetchClient;
use crate::client::llm_budget::call_llm_for_client;
use crate::llm::actions::client_trait::{Client, ClientActionResult};
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::state::app_state::AppState;
use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
use crate::state::{AccessLogOwner, ClientId, ClientStatus};
pub struct BitcoinClient;
impl BitcoinClient {
#[allow(clippy::too_many_arguments)]
pub async fn connect_with_llm_actions(
remote_addr: String,
rpc_user: Option<String>,
rpc_password: Option<String>,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
) -> Result<SocketAddr> {
info!(
"Bitcoin RPC client {} initialized for {}",
client_id,
redact_userinfo(&remote_addr)
);
let base_url = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://")
{
remote_addr.clone()
} else {
format!("http://{}", remote_addr)
};
let rpc_url = match (rpc_user, rpc_password) {
(Some(user), pass) if !user.trim().is_empty() && !has_userinfo(&base_url) => {
let (scheme, rest) = base_url
.split_once("://")
.map(|(a, b)| (a.to_string(), b.to_string()))
.unwrap_or_else(|| ("http".to_string(), base_url.clone()));
match pass {
Some(pass) => format!("{scheme}://{user}:{pass}@{rest}"),
None => format!("{scheme}://{user}@{rest}"),
}
}
_ => base_url,
};
let display_url = redact_userinfo(&rpc_url);
crate::client::http_fetch::check_url(&split_userinfo(&rpc_url).0)?;
app_state
.with_client_mut(client_id, |client| {
client.set_protocol_field("rpc_url".to_string(), serde_json::json!(rpc_url));
client.set_protocol_field(
"rpc_url_display".to_string(),
serde_json::json!(display_url),
);
client.set_protocol_field("initialized".to_string(), serde_json::json!(true));
})
.await;
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] Bitcoin RPC client {} ready for {}",
client_id, display_url
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let cmd_task = tokio::spawn(Self::command_loop(
command_rx,
client_id,
app_state.clone(),
llm_client.clone(),
status_tx.clone(),
));
app_state.register_client_task(client_id, cmd_task).await;
if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
let protocol = Arc::new(crate::client::bitcoin::actions::BitcoinClientProtocol::new());
let event = Event::new(
&crate::client::bitcoin::actions::BITCOIN_CLIENT_CONNECTED_EVENT,
serde_json::json!({
"rpc_url": display_url,
}),
);
let llm_client_clone = llm_client.clone();
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let task_registrar = app_state.clone();
let task_handle = tokio::spawn(async move {
match call_llm_for_client(
&llm_client_clone,
&app_state_clone,
client_id.to_string(),
&instruction,
&String::new(),
Some(&event),
protocol.as_ref(),
&status_tx_clone,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state_clone.set_memory_for_client(client_id, mem).await;
}
for action in actions {
let result = match protocol.execute_action(action) {
Ok(result) => result,
Err(e) => {
error!(
"Bitcoin RPC client {} rejected action: {}",
client_id, e
);
continue;
}
};
match Self::apply_action(
result,
client_id,
&app_state_clone,
&llm_client_clone,
&status_tx_clone,
)
.await
{
Ok(Applied::Ran(detail)) => {
info!("Bitcoin RPC client {}: {}", client_id, detail);
}
Ok(Applied::Disconnect) => break,
Err(e) => {
error!("Bitcoin RPC request failed: {}", e);
}
}
}
}
Err(e) => {
error!("LLM error for Bitcoin RPC client {}: {}", client_id, e);
}
}
});
task_registrar
.register_client_task(client_id, task_handle)
.await;
}
Ok("0.0.0.0:0".parse().unwrap())
}
async fn command_loop(
mut command_rx: mpsc::Receiver<ClientCommand>,
client_id: ClientId,
app_state: Arc<AppState>,
llm_client: OllamaClient,
status_tx: mpsc::UnboundedSender<String>,
) {
use crate::llm::actions::protocol_trait::Protocol;
let protocol = crate::client::bitcoin::actions::BitcoinClientProtocol::new();
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let outcome = match protocol.execute_action(action.clone()) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(result) => {
match Self::apply_action(result, client_id, &app_state, &llm_client, &status_tx)
.await
{
Ok(Applied::Ran(detail)) => Ok(ClientSendOutcome::Executed { detail }),
Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
Err(e) => Err(e),
}
}
};
let outcome_json = match &outcome {
Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({"error": e.to_string()}),
};
app_state
.record_access_log(
AccessLogOwner::Client(client_id.as_u32()),
protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
if let Err(e) = &outcome {
error!(
"Bitcoin RPC client {} injected action failed: {}",
client_id, e
);
let _ = status_tx.send(format!(
"[WARN] Client {} injected action failed: {}",
client_id, e
));
}
let _ = status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
break;
}
}
app_state.remove_client_handle(client_id).await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
info!("Bitcoin RPC client {} command loop ended", client_id);
}
async fn apply_action(
result: ClientActionResult,
client_id: ClientId,
app_state: &Arc<AppState>,
llm_client: &OllamaClient,
status_tx: &mpsc::UnboundedSender<String>,
) -> Result<Applied> {
match result {
ClientActionResult::Custom { name, data } if name == "bitcoin_rpc" => {
let method = data
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let params = data
.get("params")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let exchange =
Self::perform_rpc(client_id, method.clone(), params, app_state, status_tx)
.await?;
let detail = format!(
"bitcoin_rpc '{}' -> HTTP {} ({})",
method,
exchange.status_code,
if exchange.error.as_ref().is_some_and(|e| !e.is_null()) {
"JSON-RPC error"
} else {
"result"
}
);
let state_clone = app_state.clone();
let llm_clone = llm_client.clone();
let status_clone = status_tx.clone();
let notify_handle = tokio::spawn(async move {
Self::notify_response(
client_id,
exchange,
state_clone,
llm_clone,
status_clone,
)
.await;
});
app_state
.register_client_task(client_id, notify_handle)
.await;
Ok(Applied::Ran(detail))
}
ClientActionResult::Disconnect => {
info!("Bitcoin RPC client {} disconnecting", client_id);
Ok(Applied::Disconnect)
}
ClientActionResult::WaitForMore => Ok(Applied::Ran("wait_for_more".to_string())),
ClientActionResult::NoAction => Ok(Applied::Ran("no_action".to_string())),
ClientActionResult::Custom { name, .. } => Ok(Applied::Ran(format!(
"custom result '{name}' is not handled by the Bitcoin RPC client"
))),
ClientActionResult::SendData(_) => Ok(Applied::Ran(
"send_data has no meaning for a JSON-RPC-over-HTTP client".to_string(),
)),
ClientActionResult::Multiple(_) => Ok(Applied::Ran(
"multiple results are not produced by the Bitcoin RPC client".to_string(),
)),
}
}
pub async fn execute_rpc_command(
client_id: ClientId,
method: String,
params: Vec<serde_json::Value>,
app_state: Arc<AppState>,
llm_client: OllamaClient,
status_tx: mpsc::UnboundedSender<String>,
) -> Result<()> {
let exchange = Self::perform_rpc(client_id, method, params, &app_state, &status_tx).await?;
Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
Ok(())
}
async fn perform_rpc(
client_id: ClientId,
method: String,
params: Vec<serde_json::Value>,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
) -> Result<BitcoinRpcExchange> {
let rpc_url = app_state
.with_client_mut(client_id, |client| {
client
.get_protocol_field("rpc_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.await
.flatten()
.context("No RPC URL found")?;
info!(
"Bitcoin RPC client {} executing: {} {:?}",
client_id, method, params
);
let request_body = serde_json::json!({
"jsonrpc": "1.0",
"id": "netget",
"method": method,
"params": params,
});
#[cfg(not(target_arch = "wasm32"))]
let http_client = FetchClient::from_reqwest(
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?,
);
#[cfg(target_arch = "wasm32")]
let http_client = FetchClient::transport(std::time::Duration::from_secs(60));
let (clean_url, credential) = split_userinfo(&rpc_url);
let mut request = http_client
.post(&clean_url)
.header("Content-Type", "application/json")
.json(&request_body);
if let Some((user, pass)) = credential {
request = request.basic_auth(user, pass);
}
match request.send().await {
Ok(response) => {
let status = response.status();
let response_text = response.text().await?;
info!(
"Bitcoin RPC client {} received response: {}",
client_id, status
);
let response_json: serde_json::Value = serde_json::from_str(&response_text)
.unwrap_or(serde_json::json!({
"error": "Failed to parse response",
"raw": response_text
}));
Ok(BitcoinRpcExchange {
method,
status_code: status.as_u16(),
result: response_json.get("result").cloned(),
error: response_json.get("error").cloned(),
})
}
Err(e) => {
Log::new(Some(status_tx)).error(format!(
"Bitcoin RPC client {} request failed: {}",
client_id, e
));
Err(e)
}
}
}
async fn run_follow_ups(
client_id: ClientId,
actions: Vec<serde_json::Value>,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
) {
use crate::llm::actions::client_trait::{Client, ClientActionResult};
let protocol = crate::client::bitcoin::actions::BitcoinClientProtocol::new();
for action in actions {
let Ok(ClientActionResult::Custom { name, data }) =
protocol.execute_action(action.clone())
else {
continue;
};
if name != "bitcoin_rpc" {
info!(
"Bitcoin client {} follow-up '{}' has no non-notifying path; skipped",
client_id, name
);
continue;
}
if let Err(e) = Self::perform_rpc(
client_id,
data["method"].as_str().unwrap_or_default().to_string(),
data["params"].as_array().cloned().unwrap_or_default(),
app_state,
status_tx,
)
.await
{
error!(
"Bitcoin client {} follow-up action failed: {}",
client_id, e
);
}
}
}
async fn notify_response(
client_id: ClientId,
exchange: BitcoinRpcExchange,
app_state: Arc<AppState>,
llm_client: OllamaClient,
status_tx: mpsc::UnboundedSender<String>,
) {
let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
return;
};
let protocol = Arc::new(crate::client::bitcoin::actions::BitcoinClientProtocol::new());
let event = Event::new(
&BITCOIN_CLIENT_RESPONSE_RECEIVED_EVENT,
serde_json::json!({
"method": exchange.method,
"result": exchange.result,
"error": exchange.error,
"status_code": exchange.status_code,
}),
);
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
match call_llm_for_client(
&llm_client,
&app_state,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
protocol.as_ref(),
&status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state.set_memory_for_client(client_id, mem).await;
}
Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
}
Err(e) => {
error!("LLM error for Bitcoin RPC client {}: {}", client_id, e);
}
}
}
}
struct BitcoinRpcExchange {
method: String,
status_code: u16,
result: Option<serde_json::Value>,
error: Option<serde_json::Value>,
}
enum Applied {
Ran(String),
Disconnect,
}
pub fn has_userinfo(url: &str) -> bool {
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
authority.contains('@')
}
pub type Credential = Option<(String, Option<String>)>;
pub fn split_userinfo(url: &str) -> (String, Credential) {
let Some((scheme, rest)) = url.split_once("://") else {
return (url.to_string(), None);
};
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (authority, tail) = rest.split_at(authority_end);
let Some((userinfo, host)) = authority.rsplit_once('@') else {
return (url.to_string(), None);
};
let (user, pass) = match userinfo.split_once(':') {
Some((u, p)) => (u.to_string(), Some(p.to_string())),
None => (userinfo.to_string(), None),
};
(format!("{scheme}://{host}{tail}"), Some((user, pass)))
}
pub fn redact_userinfo(url: &str) -> String {
let (clean, credential) = split_userinfo(url);
match credential {
None => clean,
Some(_) => match clean.split_once("://") {
Some((scheme, rest)) => format!("{scheme}://***@{rest}"),
None => format!("***@{clean}"),
},
}
}