Skip to main content

chainrpc_core/
bitcoin.rs

1//! Bitcoin RPC support — method safety, CU costs, and transport.
2//!
3//! Bitcoin Core uses JSON-RPC on port 8332 with HTTP Basic Auth.
4//! This module adds Bitcoin-specific semantics.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::{Arc, OnceLock};
8
9use async_trait::async_trait;
10use crate::chain_client::{ChainBlock, ChainClient};
11use crate::error::TransportError;
12use crate::method_safety::MethodSafety;
13use crate::request::{JsonRpcRequest, JsonRpcResponse};
14use crate::transport::{HealthStatus, RpcTransport};
15
16// ---------------------------------------------------------------------------
17// Method classification
18// ---------------------------------------------------------------------------
19
20/// Classify a Bitcoin JSON-RPC method by its safety level.
21pub fn classify_bitcoin_method(method: &str) -> MethodSafety {
22    if bitcoin_unsafe_methods().contains(method) {
23        MethodSafety::Unsafe
24    } else if bitcoin_idempotent_methods().contains(method) {
25        MethodSafety::Idempotent
26    } else {
27        MethodSafety::Safe
28    }
29}
30
31pub fn is_bitcoin_safe_to_retry(method: &str) -> bool {
32    classify_bitcoin_method(method) == MethodSafety::Safe
33}
34
35pub fn is_bitcoin_safe_to_dedup(method: &str) -> bool {
36    classify_bitcoin_method(method) == MethodSafety::Safe
37}
38
39pub fn is_bitcoin_cacheable(method: &str) -> bool {
40    classify_bitcoin_method(method) == MethodSafety::Safe
41}
42
43fn bitcoin_unsafe_methods() -> &'static HashSet<&'static str> {
44    static UNSAFE: OnceLock<HashSet<&'static str>> = OnceLock::new();
45    UNSAFE.get_or_init(|| {
46        [
47            "walletpassphrase",
48            "encryptwallet",
49            "backupwallet",
50            "importprivkey",
51        ]
52        .into_iter()
53        .collect()
54    })
55}
56
57fn bitcoin_idempotent_methods() -> &'static HashSet<&'static str> {
58    static IDEMPOTENT: OnceLock<HashSet<&'static str>> = OnceLock::new();
59    IDEMPOTENT.get_or_init(|| {
60        ["sendrawtransaction"].into_iter().collect()
61    })
62}
63
64// ---------------------------------------------------------------------------
65// CU cost table
66// ---------------------------------------------------------------------------
67
68#[derive(Debug, Clone)]
69pub struct BitcoinCuCostTable {
70    costs: HashMap<String, u32>,
71    default_cost: u32,
72}
73
74impl BitcoinCuCostTable {
75    pub fn defaults() -> Self {
76        let mut table = Self::new(15);
77        let entries: &[(&str, u32)] = &[
78            ("getblockcount", 5),
79            ("getbestblockhash", 5),
80            ("getblockhash", 5),
81            ("getblock", 20),
82            ("getblockheader", 10),
83            ("getrawtransaction", 15),
84            ("gettxout", 10),
85            ("getmempoolinfo", 5),
86            ("getrawmempool", 20),
87            ("getnetworkinfo", 5),
88            ("getblockchaininfo", 10),
89            ("estimatesmartfee", 10),
90            ("sendrawtransaction", 10),
91            ("decoderawtransaction", 10),
92        ];
93        for &(method, cost) in entries {
94            table.costs.insert(method.to_string(), cost);
95        }
96        table
97    }
98
99    pub fn new(default_cost: u32) -> Self {
100        Self { costs: HashMap::new(), default_cost }
101    }
102
103    pub fn set_cost(&mut self, method: &str, cost: u32) {
104        self.costs.insert(method.to_string(), cost);
105    }
106
107    pub fn cost_for(&self, method: &str) -> u32 {
108        self.costs.get(method).copied().unwrap_or(self.default_cost)
109    }
110}
111
112impl Default for BitcoinCuCostTable {
113    fn default() -> Self {
114        Self::defaults()
115    }
116}
117
118// ---------------------------------------------------------------------------
119// Known endpoints
120// ---------------------------------------------------------------------------
121
122pub fn bitcoin_mainnet_endpoints() -> &'static [&'static str] {
123    &[
124        "https://btc.getblock.io/mainnet/",
125        "https://bitcoin-mainnet.public.blastapi.io",
126    ]
127}
128
129pub fn bitcoin_testnet_endpoints() -> &'static [&'static str] {
130    &[
131        "https://btc.getblock.io/testnet/",
132    ]
133}
134
135// ---------------------------------------------------------------------------
136// BitcoinTransport
137// ---------------------------------------------------------------------------
138
139/// Bitcoin RPC transport wrapper.
140pub struct BitcoinTransport {
141    inner: Arc<dyn RpcTransport>,
142}
143
144impl BitcoinTransport {
145    pub fn new(inner: Arc<dyn RpcTransport>) -> Self {
146        Self { inner }
147    }
148
149    pub fn inner(&self) -> &Arc<dyn RpcTransport> {
150        &self.inner
151    }
152}
153
154#[async_trait]
155impl RpcTransport for BitcoinTransport {
156    async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
157        self.inner.send(req).await
158    }
159
160    async fn send_batch(&self, reqs: Vec<JsonRpcRequest>) -> Result<Vec<JsonRpcResponse>, TransportError> {
161        self.inner.send_batch(reqs).await
162    }
163
164    fn health(&self) -> HealthStatus { self.inner.health() }
165    fn url(&self) -> &str { self.inner.url() }
166}
167
168// ---------------------------------------------------------------------------
169// BitcoinChainClient
170// ---------------------------------------------------------------------------
171
172/// Bitcoin implementation of [`ChainClient`].
173pub struct BitcoinChainClient {
174    transport: Arc<dyn RpcTransport>,
175    chain_id: String,
176}
177
178impl BitcoinChainClient {
179    pub fn new(transport: Arc<dyn RpcTransport>, chain_id: impl Into<String>) -> Self {
180        Self {
181            transport,
182            chain_id: chain_id.into(),
183        }
184    }
185}
186
187#[async_trait]
188impl ChainClient for BitcoinChainClient {
189    async fn get_head_height(&self) -> Result<u64, TransportError> {
190        let req = JsonRpcRequest::new(1, "getblockcount", vec![]);
191        let resp = self.transport.send(req).await?;
192        let result = resp.into_result().map_err(TransportError::Rpc)?;
193        result.as_u64().ok_or_else(|| {
194            TransportError::Other("expected u64 for block count".into())
195        })
196    }
197
198    async fn get_block_by_height(
199        &self,
200        height: u64,
201    ) -> Result<Option<ChainBlock>, TransportError> {
202        // Get block hash
203        let hash_req = JsonRpcRequest::new(
204            1,
205            "getblockhash",
206            vec![serde_json::json!(height)],
207        );
208        let hash_resp = self.transport.send(hash_req).await?;
209        let hash_result = hash_resp.into_result().map_err(TransportError::Rpc)?;
210        let block_hash = match hash_result.as_str() {
211            Some(h) => h.to_string(),
212            None => return Ok(None),
213        };
214
215        // Get block with verbosity=1 (includes tx IDs)
216        let block_req = JsonRpcRequest::new(
217            1,
218            "getblock",
219            vec![
220                serde_json::Value::String(block_hash.clone()),
221                serde_json::json!(1), // verbosity
222            ],
223        );
224        let block_resp = self.transport.send(block_req).await?;
225        let result = block_resp.into_result().map_err(TransportError::Rpc)?;
226
227        if result.is_null() {
228            return Ok(None);
229        }
230
231        let parent_hash = result["previousblockhash"]
232            .as_str()
233            .unwrap_or_default()
234            .to_string();
235        let timestamp = result["time"].as_i64().unwrap_or(0);
236        let tx_count = result["tx"]
237            .as_array()
238            .map(|a| a.len() as u32)
239            .unwrap_or(0);
240
241        Ok(Some(ChainBlock {
242            height,
243            hash: block_hash,
244            parent_hash,
245            timestamp,
246            tx_count,
247        }))
248    }
249
250    fn chain_id(&self) -> &str {
251        &self.chain_id
252    }
253
254    fn chain_family(&self) -> &str {
255        "bitcoin"
256    }
257
258    async fn health_check(&self) -> Result<bool, TransportError> {
259        self.get_head_height().await.map(|_| true)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::request::RpcId;
267    use serde_json::Value;
268    use std::sync::Mutex;
269
270    struct MockTransport {
271        url: String,
272        responses: Mutex<Vec<JsonRpcResponse>>,
273    }
274
275    impl MockTransport {
276        fn new(responses: Vec<JsonRpcResponse>) -> Self {
277            Self { url: "mock://btc".to_string(), responses: Mutex::new(responses) }
278        }
279    }
280
281    #[async_trait]
282    impl RpcTransport for MockTransport {
283        async fn send(&self, _req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
284            let mut r = self.responses.lock().unwrap();
285            if r.is_empty() { Err(TransportError::Other("no mock".into())) }
286            else { Ok(r.remove(0)) }
287        }
288        fn url(&self) -> &str { &self.url }
289    }
290
291    fn ok(result: Value) -> JsonRpcResponse {
292        JsonRpcResponse { jsonrpc: "2.0".into(), id: RpcId::Number(1), result: Some(result), error: None }
293    }
294
295    #[test]
296    fn classify() {
297        assert_eq!(classify_bitcoin_method("getblock"), MethodSafety::Safe);
298        assert_eq!(classify_bitcoin_method("sendrawtransaction"), MethodSafety::Idempotent);
299        assert_eq!(classify_bitcoin_method("walletpassphrase"), MethodSafety::Unsafe);
300    }
301
302    #[test]
303    fn cu_costs() {
304        let t = BitcoinCuCostTable::defaults();
305        assert_eq!(t.cost_for("getblockcount"), 5);
306        assert_eq!(t.cost_for("getblock"), 20);
307    }
308
309    #[tokio::test]
310    async fn btc_get_head_height() {
311        let t = Arc::new(MockTransport::new(vec![ok(serde_json::json!(830000u64))]));
312        let c = BitcoinChainClient::new(t, "mainnet");
313        assert_eq!(c.get_head_height().await.unwrap(), 830000);
314    }
315
316    #[tokio::test]
317    async fn btc_get_block() {
318        let t = Arc::new(MockTransport::new(vec![
319            ok(serde_json::Value::String("00000000abc".to_string())),
320            ok(serde_json::json!({
321                "previousblockhash": "00000000def",
322                "time": 1700000000,
323                "tx": ["tx1", "tx2"]
324            })),
325        ]));
326        let c = BitcoinChainClient::new(t, "mainnet");
327        let b = c.get_block_by_height(830000).await.unwrap().unwrap();
328        assert_eq!(b.hash, "00000000abc");
329        assert_eq!(b.parent_hash, "00000000def");
330        assert_eq!(b.tx_count, 2);
331    }
332
333    #[tokio::test]
334    async fn btc_metadata() {
335        let t = Arc::new(MockTransport::new(vec![]));
336        let c = BitcoinChainClient::new(t, "mainnet");
337        assert_eq!(c.chain_family(), "bitcoin");
338    }
339}