Skip to main content

netget/client/bitcoin/
actions.rs

1//! Bitcoin RPC client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15/// Bitcoin RPC client connected event
16pub static BITCOIN_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17    EventType::new(
18        "bitcoin_connected",
19        "Bitcoin RPC client initialized and ready to execute commands",
20        json!({"type": "execute_rpc", "method": "getblockchaininfo", "params": []}),
21    )
22    .with_parameters(vec![Parameter {
23        name: "rpc_url".to_string(),
24        type_hint: "string".to_string(),
25        description: "Bitcoin Core RPC URL".to_string(),
26        required: true,
27    }])
28});
29
30/// Bitcoin RPC client response received event
31pub static BITCOIN_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
32    EventType::new(
33        "bitcoin_response_received",
34        "Response received from Bitcoin Core RPC",
35        json!({"type": "execute_rpc", "method": "getblock", "params": ["00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048"]}),
36    )
37    .with_parameters(vec![
38        Parameter {
39            name: "method".to_string(),
40            type_hint: "string".to_string(),
41            description: "RPC method that was called".to_string(),
42            required: true,
43        },
44        Parameter {
45            name: "result".to_string(),
46            type_hint: "any".to_string(),
47            description: "The result from the RPC call".to_string(),
48            required: false,
49        },
50        Parameter {
51            name: "error".to_string(),
52            type_hint: "any".to_string(),
53            description: "Error message if the RPC call failed".to_string(),
54            required: false,
55        },
56        Parameter {
57            name: "status_code".to_string(),
58            type_hint: "number".to_string(),
59            description: "HTTP status code".to_string(),
60            required: true,
61        },
62    ])
63});
64
65/// Bitcoin RPC client protocol action handler
66pub struct BitcoinClientProtocol;
67
68impl BitcoinClientProtocol {
69    pub fn new() -> Self {
70        Self
71    }
72}
73
74// Implement Protocol trait (common functionality)
75impl Protocol for BitcoinClientProtocol {
76    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
77        vec![
78            ParameterDefinition {
79                name: "rpc_user".to_string(),
80                description: "Bitcoin RPC username".to_string(),
81                type_hint: "string".to_string(),
82                required: false,
83                example: json!("bitcoinrpc"),
84                default: None,
85            },
86            ParameterDefinition {
87                name: "rpc_password".to_string(),
88                description: "Bitcoin RPC password".to_string(),
89                type_hint: "string".to_string(),
90                required: false,
91                example: json!("password123"),
92                default: None,
93            },
94        ]
95    }
96    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
97        vec![
98            // Blockchain queries
99            ActionDefinition {
100                name: "get_blockchain_info".to_string(),
101                description: "Get blockchain information (chain, blocks, difficulty, etc.)"
102                    .to_string(),
103                parameters: vec![],
104                example: json!({
105                    "type": "get_blockchain_info"
106                }),
107                log_template: None,
108            },
109            ActionDefinition {
110                name: "get_block_hash".to_string(),
111                description: "Get block hash by height".to_string(),
112                parameters: vec![Parameter {
113                    name: "height".to_string(),
114                    type_hint: "number".to_string(),
115                    description: "Block height".to_string(),
116                    required: true,
117                }],
118                example: json!({
119                    "type": "get_block_hash",
120                    "height": 700000
121                }),
122                log_template: None,
123            },
124            ActionDefinition {
125                name: "get_block".to_string(),
126                description: "Get block details by hash".to_string(),
127                parameters: vec![
128                    Parameter {
129                        name: "block_hash".to_string(),
130                        type_hint: "string".to_string(),
131                        description: "Block hash (hex)".to_string(),
132                        required: true,
133                    },
134                    Parameter {
135                        name: "verbosity".to_string(),
136                        type_hint: "number".to_string(),
137                        description: "Verbosity level (0=hex, 1=json, 2=json with tx details)"
138                            .to_string(),
139                        required: false,
140                    },
141                ],
142                example: json!({
143                    "type": "get_block",
144                    "block_hash": "00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048",
145                    "verbosity": 1
146                }),
147                log_template: None,
148            },
149            ActionDefinition {
150                name: "get_transaction".to_string(),
151                description: "Get transaction details by txid".to_string(),
152                parameters: vec![Parameter {
153                    name: "txid".to_string(),
154                    type_hint: "string".to_string(),
155                    description: "Transaction ID (hex)".to_string(),
156                    required: true,
157                }],
158                example: json!({
159                    "type": "get_transaction",
160                    "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"
161                }),
162                log_template: None,
163            },
164            ActionDefinition {
165                name: "get_mempool_info".to_string(),
166                description: "Get mempool information (size, bytes, usage, etc.)".to_string(),
167                parameters: vec![],
168                example: json!({
169                    "type": "get_mempool_info"
170                }),
171                log_template: None,
172            },
173            ActionDefinition {
174                name: "get_raw_mempool".to_string(),
175                description: "Get list of transaction IDs in mempool".to_string(),
176                parameters: vec![Parameter {
177                    name: "verbose".to_string(),
178                    type_hint: "boolean".to_string(),
179                    description: "Return verbose object for each transaction".to_string(),
180                    required: false,
181                }],
182                example: json!({
183                    "type": "get_raw_mempool",
184                    "verbose": false
185                }),
186                log_template: None,
187            },
188            ActionDefinition {
189                name: "get_mining_info".to_string(),
190                description: "Get mining information (network hashrate, difficulty, etc.)"
191                    .to_string(),
192                parameters: vec![],
193                example: json!({
194                    "type": "get_mining_info"
195                }),
196                log_template: None,
197            },
198            // Network queries
199            ActionDefinition {
200                name: "get_network_info".to_string(),
201                description: "Get network information (version, connections, protocols, etc.)"
202                    .to_string(),
203                parameters: vec![],
204                example: json!({
205                    "type": "get_network_info"
206                }),
207                log_template: None,
208            },
209            ActionDefinition {
210                name: "get_peer_info".to_string(),
211                description: "Get information about connected peers".to_string(),
212                parameters: vec![],
213                example: json!({
214                    "type": "get_peer_info"
215                }),
216                log_template: None,
217            },
218            ActionDefinition {
219                name: "get_connection_count".to_string(),
220                description: "Get number of connections to other nodes".to_string(),
221                parameters: vec![],
222                example: json!({
223                    "type": "get_connection_count"
224                }),
225                log_template: None,
226            },
227            // Wallet operations
228            ActionDefinition {
229                name: "get_wallet_info".to_string(),
230                description: "Get wallet information (balance, txcount, etc.)".to_string(),
231                parameters: vec![],
232                example: json!({
233                    "type": "get_wallet_info"
234                }),
235                log_template: None,
236            },
237            ActionDefinition {
238                name: "get_balance".to_string(),
239                description: "Get wallet balance".to_string(),
240                parameters: vec![],
241                example: json!({
242                    "type": "get_balance"
243                }),
244                log_template: None,
245            },
246            ActionDefinition {
247                name: "list_transactions".to_string(),
248                description: "List wallet transactions".to_string(),
249                parameters: vec![Parameter {
250                    name: "count".to_string(),
251                    type_hint: "number".to_string(),
252                    description: "Number of transactions to list".to_string(),
253                    required: false,
254                }],
255                example: json!({
256                    "type": "list_transactions",
257                    "count": 10
258                }),
259                log_template: None,
260            },
261            // Generic RPC call
262            ActionDefinition {
263                name: "execute_rpc".to_string(),
264                description: "Execute a custom Bitcoin RPC command".to_string(),
265                parameters: vec![
266                    Parameter {
267                        name: "method".to_string(),
268                        type_hint: "string".to_string(),
269                        description: "RPC method name".to_string(),
270                        required: true,
271                    },
272                    Parameter {
273                        name: "params".to_string(),
274                        type_hint: "array".to_string(),
275                        description: "RPC parameters (array)".to_string(),
276                        required: false,
277                    },
278                ],
279                example: json!({
280                    "type": "execute_rpc",
281                    "method": "getbestblockhash",
282                    "params": []
283                }),
284                log_template: None,
285            },
286            ActionDefinition {
287                name: "disconnect".to_string(),
288                description: "Disconnect from the Bitcoin RPC server".to_string(),
289                parameters: vec![],
290                example: json!({
291                    "type": "disconnect"
292                }),
293                log_template: None,
294            },
295        ]
296    }
297    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
298        vec![ActionDefinition {
299            name: "execute_rpc".to_string(),
300            description: "Execute another RPC command in response to received data".to_string(),
301            parameters: vec![
302                Parameter {
303                    name: "method".to_string(),
304                    type_hint: "string".to_string(),
305                    description: "RPC method name".to_string(),
306                    required: true,
307                },
308                Parameter {
309                    name: "params".to_string(),
310                    type_hint: "array".to_string(),
311                    description: "RPC parameters".to_string(),
312                    required: false,
313                },
314            ],
315            example: json!({
316                "type": "execute_rpc",
317                "method": "getblock",
318                "params": ["00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048"]
319            }),
320            log_template: Some(
321                LogTemplate::new()
322                    .with_info("-> Bitcoin RPC {method}")
323                    .with_debug("Bitcoin execute_rpc: method={method}"),
324            ),
325        }]
326    }
327    fn protocol_name(&self) -> &'static str {
328        "Bitcoin"
329    }
330    /// Clone the statics the client actually emits, rather than rebuilding them.
331    ///
332    /// This used to construct a second, parameterless pair. `get_event_types()` is the copy
333    /// the model is shown — the statics are used only at the emit site — so every field
334    /// `with_parameters` declares on them was invisible to it. Two definitions of one event
335    /// drift; one cannot. (`torrent_tracker`'s client is the protocol that already had this
336    /// right, and says so in its own comment.)
337    fn get_event_types(&self) -> Vec<EventType> {
338        vec![
339            BITCOIN_CLIENT_CONNECTED_EVENT.clone(),
340            BITCOIN_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
341        ]
342    }
343    fn stack_name(&self) -> &'static str {
344        "ETH>IP>TCP>HTTP>JSON-RPC>Bitcoin"
345    }
346    fn keywords(&self) -> Vec<&'static str> {
347        vec![
348            "bitcoin",
349            "btc",
350            "bitcoin rpc",
351            "bitcoin client",
352            "blockchain",
353        ]
354    }
355    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
356        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
357
358        ProtocolMetadataV2::builder()
359            .state(DevelopmentState::Experimental)
360            .implementation("JSON-RPC over HTTP to Bitcoin Core")
361            .llm_control(
362                "Full control over Bitcoin RPC commands (blockchain, wallet, network queries)",
363            )
364            .e2e_testing("Bitcoin Core regtest node or testnet")
365            .build()
366    }
367    fn description(&self) -> &'static str {
368        "Bitcoin RPC client for blockchain queries and wallet operations"
369    }
370    fn example_prompt(&self) -> &'static str {
371        "Connect to Bitcoin Core at http://user:pass@localhost:8332 and get blockchain info"
372    }
373    fn group_name(&self) -> &'static str {
374        "Blockchain"
375    }
376    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
377        use crate::llm::actions::StartupExamples;
378        use serde_json::json;
379
380        StartupExamples::new(
381            // LLM mode: LLM handles Bitcoin RPC queries
382            json!({
383                "type": "open_client",
384                "remote_addr": "localhost:8332",
385                "base_stack": "bitcoin",
386                "instruction": "Connect to Bitcoin Core and get blockchain info",
387                "startup_params": {
388                    "rpc_user": "bitcoinrpc",
389                    "rpc_password": "password"
390                }
391            }),
392            // Script mode: Code-based Bitcoin RPC handling
393            json!({
394                "type": "open_client",
395                "remote_addr": "localhost:8332",
396                "base_stack": "bitcoin",
397                "startup_params": {
398                    "rpc_user": "bitcoinrpc",
399                    "rpc_password": "password"
400                },
401                "event_handlers": [{
402                    "event_pattern": "bitcoin_connected",
403                    "handler": {
404                        "type": "script",
405                        "language": "python",
406                        "code": "<bitcoin_client_handler>"
407                    }
408                }]
409            }),
410            // Static mode: Fixed Bitcoin RPC action
411            json!({
412                "type": "open_client",
413                "remote_addr": "localhost:8332",
414                "base_stack": "bitcoin",
415                "startup_params": {
416                    "rpc_user": "bitcoinrpc",
417                    "rpc_password": "password"
418                },
419                "event_handlers": [{
420                    "event_pattern": "bitcoin_connected",
421                    "handler": {
422                        "type": "static",
423                        "actions": [{
424                            "type": "get_blockchain_info"
425                        }]
426                    }
427                }]
428            }),
429        )
430    }
431}
432
433// Implement Client trait (client-specific functionality)
434impl Client for BitcoinClientProtocol {
435    fn connect(
436        &self,
437        ctx: crate::protocol::ConnectContext,
438    ) -> std::pin::Pin<
439        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
440    > {
441        Box::pin(async move {
442            use crate::client::bitcoin::BitcoinClient;
443            // Declared and, until now, read by nothing. bitcoind's RPC is auth-mandatory, so
444            // a client that never sends a credential cannot complete a single call against a
445            // real node.
446            let rpc_user = ctx
447                .startup_params
448                .as_ref()
449                .map(|p| p.get_optional_string("rpc_user"))
450                .transpose()?
451                .flatten();
452            let rpc_password = ctx
453                .startup_params
454                .as_ref()
455                .map(|p| p.get_optional_string("rpc_password"))
456                .transpose()?
457                .flatten();
458
459            BitcoinClient::connect_with_llm_actions(
460                ctx.remote_addr,
461                rpc_user,
462                rpc_password,
463                ctx.llm_client,
464                ctx.state,
465                ctx.status_tx,
466                ctx.client_id,
467            )
468            .await
469        })
470    }
471    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
472        let action_type = action
473            .get("type")
474            .and_then(|v| v.as_str())
475            .context("Missing 'type' field in action")?;
476
477        match action_type {
478            "get_blockchain_info" => Ok(ClientActionResult::Custom {
479                name: "bitcoin_rpc".to_string(),
480                data: json!({
481                    "method": "getblockchaininfo",
482                    "params": []
483                }),
484            }),
485            "get_block_hash" => {
486                let height = action
487                    .get("height")
488                    .and_then(|v| v.as_u64())
489                    .context("Missing or invalid 'height' field")?;
490
491                Ok(ClientActionResult::Custom {
492                    name: "bitcoin_rpc".to_string(),
493                    data: json!({
494                        "method": "getblockhash",
495                        "params": [height]
496                    }),
497                })
498            }
499            "get_block" => {
500                let block_hash = action
501                    .get("block_hash")
502                    .and_then(|v| v.as_str())
503                    .context("Missing 'block_hash' field")?;
504
505                let verbosity = action
506                    .get("verbosity")
507                    .and_then(|v| v.as_u64())
508                    .unwrap_or(1);
509
510                Ok(ClientActionResult::Custom {
511                    name: "bitcoin_rpc".to_string(),
512                    data: json!({
513                        "method": "getblock",
514                        "params": [block_hash, verbosity]
515                    }),
516                })
517            }
518            "get_transaction" => {
519                let txid = action
520                    .get("txid")
521                    .and_then(|v| v.as_str())
522                    .context("Missing 'txid' field")?;
523
524                Ok(ClientActionResult::Custom {
525                    name: "bitcoin_rpc".to_string(),
526                    data: json!({
527                        "method": "getrawtransaction",
528                        "params": [txid, true]
529                    }),
530                })
531            }
532            "get_mempool_info" => Ok(ClientActionResult::Custom {
533                name: "bitcoin_rpc".to_string(),
534                data: json!({
535                    "method": "getmempoolinfo",
536                    "params": []
537                }),
538            }),
539            "get_raw_mempool" => {
540                let verbose = action
541                    .get("verbose")
542                    .and_then(|v| v.as_bool())
543                    .unwrap_or(false);
544
545                Ok(ClientActionResult::Custom {
546                    name: "bitcoin_rpc".to_string(),
547                    data: json!({
548                        "method": "getrawmempool",
549                        "params": [verbose]
550                    }),
551                })
552            }
553            "get_mining_info" => Ok(ClientActionResult::Custom {
554                name: "bitcoin_rpc".to_string(),
555                data: json!({
556                    "method": "getmininginfo",
557                    "params": []
558                }),
559            }),
560            "get_network_info" => Ok(ClientActionResult::Custom {
561                name: "bitcoin_rpc".to_string(),
562                data: json!({
563                    "method": "getnetworkinfo",
564                    "params": []
565                }),
566            }),
567            "get_peer_info" => Ok(ClientActionResult::Custom {
568                name: "bitcoin_rpc".to_string(),
569                data: json!({
570                    "method": "getpeerinfo",
571                    "params": []
572                }),
573            }),
574            "get_connection_count" => Ok(ClientActionResult::Custom {
575                name: "bitcoin_rpc".to_string(),
576                data: json!({
577                    "method": "getconnectioncount",
578                    "params": []
579                }),
580            }),
581            "get_wallet_info" => Ok(ClientActionResult::Custom {
582                name: "bitcoin_rpc".to_string(),
583                data: json!({
584                    "method": "getwalletinfo",
585                    "params": []
586                }),
587            }),
588            "get_balance" => Ok(ClientActionResult::Custom {
589                name: "bitcoin_rpc".to_string(),
590                data: json!({
591                    "method": "getbalance",
592                    "params": []
593                }),
594            }),
595            "list_transactions" => {
596                let count = action.get("count").and_then(|v| v.as_u64()).unwrap_or(10);
597
598                Ok(ClientActionResult::Custom {
599                    name: "bitcoin_rpc".to_string(),
600                    data: json!({
601                        "method": "listtransactions",
602                        "params": ["*", count]
603                    }),
604                })
605            }
606            "execute_rpc" => {
607                let method = action
608                    .get("method")
609                    .and_then(|v| v.as_str())
610                    .context("Missing 'method' field")?
611                    .to_string();
612
613                let params = action
614                    .get("params")
615                    .and_then(|v| v.as_array())
616                    .cloned()
617                    .unwrap_or_default();
618
619                Ok(ClientActionResult::Custom {
620                    name: "bitcoin_rpc".to_string(),
621                    data: json!({
622                        "method": method,
623                        "params": params
624                    }),
625                })
626            }
627            "disconnect" => Ok(ClientActionResult::Disconnect),
628            _ => Err(anyhow::anyhow!(
629                "Unknown Bitcoin RPC client action: {}",
630                action_type
631            )),
632        }
633    }
634}