Skip to main content

netget/client/bitcoin/
mod.rs

1//! Bitcoin RPC client implementation
2pub 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
24/// Bitcoin RPC client that connects to Bitcoin Core node
25pub struct BitcoinClient;
26
27impl BitcoinClient {
28    /// Connect to a Bitcoin Core RPC server with integrated LLM actions
29    #[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        // For Bitcoin RPC, "connection" is logical (HTTP-based JSON-RPC)
40        // We don't maintain a persistent connection, but verify connectivity
41
42        info!(
43            "Bitcoin RPC client {} initialized for {}",
44            client_id,
45            redact_userinfo(&remote_addr)
46        );
47
48        // Parse remote_addr to extract RPC URL
49        // Expected format: "http://user:pass@host:port" or "host:port"
50        let base_url = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://")
51        {
52            remote_addr.clone()
53        } else {
54            // Default to http://
55            format!("http://{}", remote_addr)
56        };
57
58        // `rpc_user` and `rpc_password` are declared startup parameters that nothing read, and
59        // bitcoind's RPC is auth-mandatory — it answers every unauthenticated request with 401 —
60        // so authenticated Bitcoin Core RPC could not work at all through this client.
61        //
62        // They are folded into the URL's userinfo rather than stored separately, because
63        // `http://user:pass@host:port` is the format this client already documents and accepts.
64        // One place therefore carries the credential and one place applies it, which is what
65        // makes the documented form work too: see `split_userinfo`.
66        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        // The credential must not leave this function in anything anyone reads. `rpc_url` is
81        // rendered in the dashboard's facts line, and the connected event below is handed to the
82        // **model**, so a userinfo URL put a password into the prompt and onto the screen. Both
83        // now carry the redacted form; `perform_rpc` re-reads the real one from `rpc_url`.
84        let display_url = redact_userinfo(&rpc_url);
85        // In the browser build the transport has no TLS: an `https://` node is refused here, with
86        // the reason (and without the credential), rather than on the first call.
87        crate::client::http_fetch::check_url(&split_userinfo(&rpc_url).0)?;
88
89        // Store RPC URL and auth in protocol_data
90        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        // Update status
102        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        // Command channel for injected actions (the dashboard's [ send ] / composer).
112        // Registered BEFORE the connected-event LLM call, which a manual `*` routing rule
113        // can park for minutes - the operator must be able to issue an RPC while it waits.
114        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        // Call LLM to decide initial action
126        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                    // Redacted: this goes to the model.
132                    "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            // Registered with AppState so stop_client can abort this task —
140            // dropping a JoinHandle only detaches it in Tokio.
141            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                        // Update memory
160                        if let Some(mem) = memory_updates {
161                            app_state_clone.set_memory_for_client(client_id, mem).await;
162                        }
163
164                        // Execute actions through the same path injected commands use, so
165                        // the JSON-RPC encoding exists exactly once.
166                        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        // No idle-poll task: the command loop above is this client's only long-lived task
207        // and it ends when the client is removed (`remove_client` drops the command sender,
208        // so `recv()` returns `None`).
209
210        // Return a dummy local address (Bitcoin RPC is connectionless HTTP)
211        Ok("0.0.0.0:0".parse().unwrap())
212    }
213
214    /// Drain injected commands until the channel closes (the client was removed) or an
215    /// injected `disconnect` ends the session.
216    ///
217    /// `command_support::handle_stream_client_command` cannot serve this client: there is
218    /// no write half, and every Bitcoin verb yields `ClientActionResult::Custom`. So the
219    /// action goes through [`Self::apply_action`] - the same function the connected-event
220    /// path uses - and the outcome is recorded and replied exactly the way the generic arm
221    /// does it.
222    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                        // Never `Sent`: reqwest owns the socket and never reports how many
244                        // bytes the request serialised to, so a byte count here would be
245                        // invented. `Executed` carries the JSON-RPC method and the HTTP
246                        // status instead, which is both true and more useful.
247                        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        // Nothing can be injected any more: stop the dashboard offering [ send ].
292        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    /// Turn one executed action into a JSON-RPC call (or a session end).
298    ///
299    /// Shared by the connected-event handler and the injected-command loop so the
300    /// `bitcoin_rpc` decoding exists exactly once. The HTTP round-trip is awaited - so
301    /// the reported detail describes an exchange that really happened - while the
302    /// `bitcoin_response_received` LLM call it triggers runs in its own registered task.
303    /// That split matters: a client whose events are routed to a manual handler would
304    /// otherwise park the command loop for the length of a human's think time.
305    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            // Not swallowed: an action this client cannot carry out says so, rather than
365            // looking identical to success.
366            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    /// Execute a Bitcoin RPC command and hand the response to the LLM.
379    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    /// Do the JSON-RPC round-trip. No LLM involvement, so a caller can await it and
393    /// report truthfully what the node answered.
394    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        // Get RPC URL from client
402        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        // Build JSON-RPC request
419        let request_body = serde_json::json!({
420            "jsonrpc": "1.0",
421            "id": "netget",
422            "method": method,
423            "params": params,
424        });
425
426        // Make HTTP POST request to Bitcoin RPC
427        // reqwest natively; in the browser build the shared hyper transport over the virtual
428        // loopback (`crate::client::http_fetch`), with the same 60-second bound.
429        #[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        // Split the credential out of the URL and send it as a real `Authorization: Basic`
439        // header. reqwest does **not** derive Basic auth from URL userinfo, so posting the
440        // userinfo URL straight at bitcoind produced a 401 on every call — the
441        // `http://user:pass@host:port` form this client documents has never worked.
442        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                // Get response body
457                let response_text = response.text().await?;
458
459                info!(
460                    "Bitcoin RPC client {} received response: {}",
461                    client_id, status
462                );
463
464                // Parse JSON-RPC response
465                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    /// Raise `bitcoin_response_received` for a completed exchange.
489    /// Run the actions the model returned for a response event.
490    ///
491    /// They were discarded (`actions: _`), so answering bitcoin_response_received did nothing -- the whole
492    /// point of raising it. Dispatch goes through `perform_rpc`, which raises no event: that
493    /// bounds the loop, and avoids a notify -> perform -> notify chain that rustc cannot
494    /// prove `Send`.
495    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                // Update memory
577                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                // Note: We don't execute follow-up actions here.
583                // The LLM will be called again on the next response or user interaction.
584                // This avoids recursion complexity and keeps the action flow simple.
585            }
586            Err(e) => {
587                error!("LLM error for Bitcoin RPC client {}: {}", client_id, e);
588            }
589        }
590    }
591}
592
593/// One completed Bitcoin JSON-RPC exchange.
594///
595/// Split out of [`BitcoinClient::execute_rpc_command`] so the injected-command loop can
596/// await the network round-trip - and report a truthful outcome - without also awaiting
597/// the LLM call the response event triggers.
598struct BitcoinRpcExchange {
599    method: String,
600    status_code: u16,
601    result: Option<serde_json::Value>,
602    error: Option<serde_json::Value>,
603}
604
605/// What one executed action did. Shared vocabulary between the connected-event handler
606/// and the injected-command loop.
607enum Applied {
608    /// The action ran; `detail` says what it did.
609    Ran(String),
610    /// The action asked to end the session.
611    Disconnect,
612}
613
614/// Does this URL already carry userinfo (`scheme://user:pass@host`)?
615///
616/// Only the authority is considered: an `@` in the path is not userinfo.
617pub 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
623/// Split `scheme://user:pass@host/path` into the URL without the credential, and the credential.
624///
625/// The credential must not stay in the request URL: some servers log the request line, and
626/// bitcoind wants it in `Authorization` regardless. Returns the URL unchanged and `None` when
627/// there is no userinfo.
628///
629/// Splitting is done by hand rather than with a URL crate because the only decision here is
630/// where the authority ends, and a parser would also percent-decode — which this deliberately
631/// does not do, since it would change a password containing `%` into a different password.
632/// A credential needing percent-encoding should be passed through `rpc_user`/`rpc_password`,
633/// where no encoding is involved at all.
634pub 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    // The authority ends at the first `/`, `?` or `#`.
641    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
642    let (authority, tail) = rest.split_at(authority_end);
643    // Last `@` wins: a password may legitimately contain one.
644    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
654/// Replace a URL's userinfo with `***` for anything a human or the model will read.
655///
656/// `http://alice:hunter2@127.0.0.1:8332` becomes `http://***@127.0.0.1:8332`. A URL with no
657/// userinfo comes back unchanged.
658pub 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}