1pub mod actions;
3
4pub use actions::BitcoinClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info};
11
12use crate::client::bitcoin::actions::BITCOIN_CLIENT_RESPONSE_RECEIVED_EVENT;
13use crate::client::http_fetch::FetchClient;
14use crate::client::llm_budget::call_llm_for_client;
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ClientLlmResult;
18use crate::logging::emit::Log;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23
24pub struct BitcoinClient;
26
27impl BitcoinClient {
28 #[allow(clippy::too_many_arguments)]
30 pub async fn connect_with_llm_actions(
31 remote_addr: String,
32 rpc_user: Option<String>,
33 rpc_password: Option<String>,
34 llm_client: OllamaClient,
35 app_state: Arc<AppState>,
36 status_tx: mpsc::UnboundedSender<String>,
37 client_id: ClientId,
38 ) -> Result<SocketAddr> {
39 info!(
43 "Bitcoin RPC client {} initialized for {}",
44 client_id,
45 redact_userinfo(&remote_addr)
46 );
47
48 let base_url = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://")
51 {
52 remote_addr.clone()
53 } else {
54 format!("http://{}", remote_addr)
56 };
57
58 let rpc_url = match (rpc_user, rpc_password) {
67 (Some(user), pass) if !user.trim().is_empty() && !has_userinfo(&base_url) => {
68 let (scheme, rest) = base_url
69 .split_once("://")
70 .map(|(a, b)| (a.to_string(), b.to_string()))
71 .unwrap_or_else(|| ("http".to_string(), base_url.clone()));
72 match pass {
73 Some(pass) => format!("{scheme}://{user}:{pass}@{rest}"),
74 None => format!("{scheme}://{user}@{rest}"),
75 }
76 }
77 _ => base_url,
78 };
79
80 let display_url = redact_userinfo(&rpc_url);
85 crate::client::http_fetch::check_url(&split_userinfo(&rpc_url).0)?;
88
89 app_state
91 .with_client_mut(client_id, |client| {
92 client.set_protocol_field("rpc_url".to_string(), serde_json::json!(rpc_url));
93 client.set_protocol_field(
94 "rpc_url_display".to_string(),
95 serde_json::json!(display_url),
96 );
97 client.set_protocol_field("initialized".to_string(), serde_json::json!(true));
98 })
99 .await;
100
101 app_state
103 .update_client_status(client_id, ClientStatus::Connected)
104 .await;
105 let _ = status_tx.send(format!(
106 "[CLIENT] Bitcoin RPC client {} ready for {}",
107 client_id, display_url
108 ));
109 let _ = status_tx.send("__UPDATE_UI__".to_string());
110
111 let command_rx =
115 crate::client::command_support::register_command_channel(&app_state, client_id).await;
116 let cmd_task = tokio::spawn(Self::command_loop(
117 command_rx,
118 client_id,
119 app_state.clone(),
120 llm_client.clone(),
121 status_tx.clone(),
122 ));
123 app_state.register_client_task(client_id, cmd_task).await;
124
125 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
127 let protocol = Arc::new(crate::client::bitcoin::actions::BitcoinClientProtocol::new());
128 let event = Event::new(
129 &crate::client::bitcoin::actions::BITCOIN_CLIENT_CONNECTED_EVENT,
130 serde_json::json!({
131 "rpc_url": display_url,
133 }),
134 );
135
136 let llm_client_clone = llm_client.clone();
137 let app_state_clone = app_state.clone();
138 let status_tx_clone = status_tx.clone();
139 let task_registrar = app_state.clone();
142 let task_handle = tokio::spawn(async move {
143 match call_llm_for_client(
144 &llm_client_clone,
145 &app_state_clone,
146 client_id.to_string(),
147 &instruction,
148 &String::new(),
149 Some(&event),
150 protocol.as_ref(),
151 &status_tx_clone,
152 )
153 .await
154 {
155 Ok(ClientLlmResult {
156 actions,
157 memory_updates,
158 }) => {
159 if let Some(mem) = memory_updates {
161 app_state_clone.set_memory_for_client(client_id, mem).await;
162 }
163
164 for action in actions {
167 let result = match protocol.execute_action(action) {
168 Ok(result) => result,
169 Err(e) => {
170 error!(
171 "Bitcoin RPC client {} rejected action: {}",
172 client_id, e
173 );
174 continue;
175 }
176 };
177 match Self::apply_action(
178 result,
179 client_id,
180 &app_state_clone,
181 &llm_client_clone,
182 &status_tx_clone,
183 )
184 .await
185 {
186 Ok(Applied::Ran(detail)) => {
187 info!("Bitcoin RPC client {}: {}", client_id, detail);
188 }
189 Ok(Applied::Disconnect) => break,
190 Err(e) => {
191 error!("Bitcoin RPC request failed: {}", e);
192 }
193 }
194 }
195 }
196 Err(e) => {
197 error!("LLM error for Bitcoin RPC client {}: {}", client_id, e);
198 }
199 }
200 });
201 task_registrar
202 .register_client_task(client_id, task_handle)
203 .await;
204 }
205
206 Ok("0.0.0.0:0".parse().unwrap())
212 }
213
214 async fn command_loop(
223 mut command_rx: mpsc::Receiver<ClientCommand>,
224 client_id: ClientId,
225 app_state: Arc<AppState>,
226 llm_client: OllamaClient,
227 status_tx: mpsc::UnboundedSender<String>,
228 ) {
229 use crate::llm::actions::protocol_trait::Protocol;
230
231 let protocol = crate::client::bitcoin::actions::BitcoinClientProtocol::new();
232
233 while let Some(command) = command_rx.recv().await {
234 let action = command.action.clone();
235 let outcome = match protocol.execute_action(action.clone()) {
236 Err(e) => Ok(ClientSendOutcome::Rejected {
237 error: e.to_string(),
238 }),
239 Ok(result) => {
240 match Self::apply_action(result, client_id, &app_state, &llm_client, &status_tx)
241 .await
242 {
243 Ok(Applied::Ran(detail)) => Ok(ClientSendOutcome::Executed { detail }),
248 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
249 Err(e) => Err(e),
250 }
251 }
252 };
253
254 let outcome_json = match &outcome {
255 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
256 Err(e) => serde_json::json!({"error": e.to_string()}),
257 };
258 app_state
259 .record_access_log(
260 AccessLogOwner::Client(client_id.as_u32()),
261 protocol.protocol_name(),
262 None,
263 "injected_action",
264 action,
265 vec![outcome_json],
266 )
267 .await;
268
269 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
270 if let Err(e) = &outcome {
271 error!(
272 "Bitcoin RPC client {} injected action failed: {}",
273 client_id, e
274 );
275 let _ = status_tx.send(format!(
276 "[WARN] Client {} injected action failed: {}",
277 client_id, e
278 ));
279 }
280 let _ = status_tx.send("__UPDATE_UI__".to_string());
281 crate::client::command_support::reply(command, outcome);
282
283 if disconnect {
284 app_state
285 .update_client_status(client_id, ClientStatus::Disconnected)
286 .await;
287 break;
288 }
289 }
290
291 app_state.remove_client_handle(client_id).await;
293 let _ = status_tx.send("__UPDATE_UI__".to_string());
294 info!("Bitcoin RPC client {} command loop ended", client_id);
295 }
296
297 async fn apply_action(
306 result: ClientActionResult,
307 client_id: ClientId,
308 app_state: &Arc<AppState>,
309 llm_client: &OllamaClient,
310 status_tx: &mpsc::UnboundedSender<String>,
311 ) -> Result<Applied> {
312 match result {
313 ClientActionResult::Custom { name, data } if name == "bitcoin_rpc" => {
314 let method = data
315 .get("method")
316 .and_then(|v| v.as_str())
317 .unwrap_or("")
318 .to_string();
319 let params = data
320 .get("params")
321 .and_then(|v| v.as_array())
322 .cloned()
323 .unwrap_or_default();
324
325 let exchange =
326 Self::perform_rpc(client_id, method.clone(), params, app_state, status_tx)
327 .await?;
328 let detail = format!(
329 "bitcoin_rpc '{}' -> HTTP {} ({})",
330 method,
331 exchange.status_code,
332 if exchange.error.as_ref().is_some_and(|e| !e.is_null()) {
333 "JSON-RPC error"
334 } else {
335 "result"
336 }
337 );
338
339 let state_clone = app_state.clone();
340 let llm_clone = llm_client.clone();
341 let status_clone = status_tx.clone();
342 let notify_handle = tokio::spawn(async move {
343 Self::notify_response(
344 client_id,
345 exchange,
346 state_clone,
347 llm_clone,
348 status_clone,
349 )
350 .await;
351 });
352 app_state
353 .register_client_task(client_id, notify_handle)
354 .await;
355
356 Ok(Applied::Ran(detail))
357 }
358 ClientActionResult::Disconnect => {
359 info!("Bitcoin RPC client {} disconnecting", client_id);
360 Ok(Applied::Disconnect)
361 }
362 ClientActionResult::WaitForMore => Ok(Applied::Ran("wait_for_more".to_string())),
363 ClientActionResult::NoAction => Ok(Applied::Ran("no_action".to_string())),
364 ClientActionResult::Custom { name, .. } => Ok(Applied::Ran(format!(
367 "custom result '{name}' is not handled by the Bitcoin RPC client"
368 ))),
369 ClientActionResult::SendData(_) => Ok(Applied::Ran(
370 "send_data has no meaning for a JSON-RPC-over-HTTP client".to_string(),
371 )),
372 ClientActionResult::Multiple(_) => Ok(Applied::Ran(
373 "multiple results are not produced by the Bitcoin RPC client".to_string(),
374 )),
375 }
376 }
377
378 pub async fn execute_rpc_command(
380 client_id: ClientId,
381 method: String,
382 params: Vec<serde_json::Value>,
383 app_state: Arc<AppState>,
384 llm_client: OllamaClient,
385 status_tx: mpsc::UnboundedSender<String>,
386 ) -> Result<()> {
387 let exchange = Self::perform_rpc(client_id, method, params, &app_state, &status_tx).await?;
388 Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
389 Ok(())
390 }
391
392 async fn perform_rpc(
395 client_id: ClientId,
396 method: String,
397 params: Vec<serde_json::Value>,
398 app_state: &Arc<AppState>,
399 status_tx: &mpsc::UnboundedSender<String>,
400 ) -> Result<BitcoinRpcExchange> {
401 let rpc_url = app_state
403 .with_client_mut(client_id, |client| {
404 client
405 .get_protocol_field("rpc_url")
406 .and_then(|v| v.as_str())
407 .map(|s| s.to_string())
408 })
409 .await
410 .flatten()
411 .context("No RPC URL found")?;
412
413 info!(
414 "Bitcoin RPC client {} executing: {} {:?}",
415 client_id, method, params
416 );
417
418 let request_body = serde_json::json!({
420 "jsonrpc": "1.0",
421 "id": "netget",
422 "method": method,
423 "params": params,
424 });
425
426 #[cfg(not(target_arch = "wasm32"))]
430 let http_client = FetchClient::from_reqwest(
431 reqwest::Client::builder()
432 .timeout(std::time::Duration::from_secs(60))
433 .build()?,
434 );
435 #[cfg(target_arch = "wasm32")]
436 let http_client = FetchClient::transport(std::time::Duration::from_secs(60));
437
438 let (clean_url, credential) = split_userinfo(&rpc_url);
443
444 let mut request = http_client
445 .post(&clean_url)
446 .header("Content-Type", "application/json")
447 .json(&request_body);
448 if let Some((user, pass)) = credential {
449 request = request.basic_auth(user, pass);
450 }
451
452 match request.send().await {
453 Ok(response) => {
454 let status = response.status();
455
456 let response_text = response.text().await?;
458
459 info!(
460 "Bitcoin RPC client {} received response: {}",
461 client_id, status
462 );
463
464 let response_json: serde_json::Value = serde_json::from_str(&response_text)
466 .unwrap_or(serde_json::json!({
467 "error": "Failed to parse response",
468 "raw": response_text
469 }));
470
471 Ok(BitcoinRpcExchange {
472 method,
473 status_code: status.as_u16(),
474 result: response_json.get("result").cloned(),
475 error: response_json.get("error").cloned(),
476 })
477 }
478 Err(e) => {
479 Log::new(Some(status_tx)).error(format!(
480 "Bitcoin RPC client {} request failed: {}",
481 client_id, e
482 ));
483 Err(e)
484 }
485 }
486 }
487
488 async fn run_follow_ups(
496 client_id: ClientId,
497 actions: Vec<serde_json::Value>,
498 app_state: &Arc<AppState>,
499 status_tx: &mpsc::UnboundedSender<String>,
500 ) {
501 use crate::llm::actions::client_trait::{Client, ClientActionResult};
502 let protocol = crate::client::bitcoin::actions::BitcoinClientProtocol::new();
503 for action in actions {
504 let Ok(ClientActionResult::Custom { name, data }) =
505 protocol.execute_action(action.clone())
506 else {
507 continue;
508 };
509 if name != "bitcoin_rpc" {
510 info!(
511 "Bitcoin client {} follow-up '{}' has no non-notifying path; skipped",
512 client_id, name
513 );
514 continue;
515 }
516 if let Err(e) = Self::perform_rpc(
517 client_id,
518 data["method"].as_str().unwrap_or_default().to_string(),
519 data["params"].as_array().cloned().unwrap_or_default(),
520 app_state,
521 status_tx,
522 )
523 .await
524 {
525 error!(
526 "Bitcoin client {} follow-up action failed: {}",
527 client_id, e
528 );
529 }
530 }
531 }
532
533 async fn notify_response(
534 client_id: ClientId,
535 exchange: BitcoinRpcExchange,
536 app_state: Arc<AppState>,
537 llm_client: OllamaClient,
538 status_tx: mpsc::UnboundedSender<String>,
539 ) {
540 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
541 return;
542 };
543
544 let protocol = Arc::new(crate::client::bitcoin::actions::BitcoinClientProtocol::new());
545 let event = Event::new(
546 &BITCOIN_CLIENT_RESPONSE_RECEIVED_EVENT,
547 serde_json::json!({
548 "method": exchange.method,
549 "result": exchange.result,
550 "error": exchange.error,
551 "status_code": exchange.status_code,
552 }),
553 );
554
555 let memory = app_state
556 .get_memory_for_client(client_id)
557 .await
558 .unwrap_or_default();
559
560 match call_llm_for_client(
561 &llm_client,
562 &app_state,
563 client_id.to_string(),
564 &instruction,
565 &memory,
566 Some(&event),
567 protocol.as_ref(),
568 &status_tx,
569 )
570 .await
571 {
572 Ok(ClientLlmResult {
573 actions,
574 memory_updates,
575 }) => {
576 if let Some(mem) = memory_updates {
578 app_state.set_memory_for_client(client_id, mem).await;
579 }
580 Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
581
582 }
586 Err(e) => {
587 error!("LLM error for Bitcoin RPC client {}: {}", client_id, e);
588 }
589 }
590 }
591}
592
593struct BitcoinRpcExchange {
599 method: String,
600 status_code: u16,
601 result: Option<serde_json::Value>,
602 error: Option<serde_json::Value>,
603}
604
605enum Applied {
608 Ran(String),
610 Disconnect,
612}
613
614pub fn has_userinfo(url: &str) -> bool {
618 let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
619 let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
620 authority.contains('@')
621}
622
623pub type Credential = Option<(String, Option<String>)>;
635
636pub fn split_userinfo(url: &str) -> (String, Credential) {
637 let Some((scheme, rest)) = url.split_once("://") else {
638 return (url.to_string(), None);
639 };
640 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
642 let (authority, tail) = rest.split_at(authority_end);
643 let Some((userinfo, host)) = authority.rsplit_once('@') else {
645 return (url.to_string(), None);
646 };
647 let (user, pass) = match userinfo.split_once(':') {
648 Some((u, p)) => (u.to_string(), Some(p.to_string())),
649 None => (userinfo.to_string(), None),
650 };
651 (format!("{scheme}://{host}{tail}"), Some((user, pass)))
652}
653
654pub fn redact_userinfo(url: &str) -> String {
659 let (clean, credential) = split_userinfo(url);
660 match credential {
661 None => clean,
662 Some(_) => match clean.split_once("://") {
663 Some((scheme, rest)) => format!("{scheme}://***@{rest}"),
664 None => format!("***@{clean}"),
665 },
666 }
667}