1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use hmac::{Hmac, KeyInit, Mac};
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use serde_json::{Value, json};
5use sha2::Sha256;
6use thiserror::Error;
7use tokio::time::sleep;
8
9use crate::{
10 DEFAULT_BITCOIND_IMAGE, RetryPolicy,
11 docker::{
12 ContainerRole, ContainerSpec, DockerClient, DockerError, SpawnedContainer,
13 managed_container_labels,
14 },
15};
16
17pub const DEFAULT_BITCOIN_RPC_USER: &str = "bitcoinrpc";
19pub const DEFAULT_BITCOIN_WALLET_NAME: &str = "spawn-lnd";
21pub const DEFAULT_BITCOIN_WALLET_MATURITY_BLOCKS: u64 = 150;
23pub const BITCOIND_RPC_PORT: u16 = 18443;
25pub const BITCOIND_P2P_PORT: u16 = 18444;
27
28type HmacSha256 = Hmac<Sha256>;
29
30#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
32pub struct BitcoinCoreConfig {
33 pub cluster_id: String,
35 pub group_index: usize,
37 pub image: String,
39 pub startup_retry: RetryPolicy,
41}
42
43impl BitcoinCoreConfig {
44 pub fn new(cluster_id: impl Into<String>, group_index: usize) -> Self {
46 Self {
47 cluster_id: cluster_id.into(),
48 group_index,
49 image: DEFAULT_BITCOIND_IMAGE.to_string(),
50 startup_retry: RetryPolicy::default(),
51 }
52 }
53
54 pub fn image(mut self, image: impl Into<String>) -> Self {
56 self.image = image.into();
57 self
58 }
59
60 pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
62 self.startup_retry = policy;
63 self
64 }
65}
66
67#[derive(Clone, Debug)]
69pub struct BitcoinCore {
70 pub container: SpawnedContainer,
72 pub auth: BitcoinRpcAuth,
74 pub rpc: BitcoinRpcClient,
76 pub wallet_rpc: BitcoinRpcClient,
78 pub rpc_socket: String,
80 pub p2p_socket: String,
82}
83
84impl BitcoinCore {
85 pub async fn spawn(
87 docker: &DockerClient,
88 config: BitcoinCoreConfig,
89 ) -> Result<Self, BitcoinCoreError> {
90 let auth = BitcoinRpcAuth::random();
91 let spec = bitcoind_container_spec(&config, &auth);
92 let container = docker.create_and_start(spec).await?;
93 let container_id = container.id.clone();
94 let core = match Self::from_container(container, auth) {
95 Ok(core) => core,
96 Err(error) => {
97 let logs = docker.container_logs(&container_id).await.ok();
98 let _ = docker.rollback_containers([container_id.clone()]).await;
99 return Err(BitcoinCoreError::Startup {
100 container_id,
101 logs,
102 source: Box::new(error),
103 });
104 }
105 };
106
107 if let Err(source) = core.wait_ready_with_policy(&config.startup_retry).await {
108 let logs = docker.container_logs(&core.container.id).await.ok();
109 let container_id = core.container.id.clone();
110 let _ = docker.rollback_containers([container_id.clone()]).await;
111 return Err(BitcoinCoreError::Startup {
112 container_id,
113 logs,
114 source: Box::new(source),
115 });
116 }
117
118 Ok(core)
119 }
120
121 fn from_container(
122 container: SpawnedContainer,
123 auth: BitcoinRpcAuth,
124 ) -> Result<Self, BitcoinCoreError> {
125 let rpc_port = container.host_port(BITCOIND_RPC_PORT).ok_or_else(|| {
126 BitcoinCoreError::MissingHostPort {
127 container_id: container.id.clone(),
128 container_port: BITCOIND_RPC_PORT,
129 }
130 })?;
131 let p2p_port = container.host_port(BITCOIND_P2P_PORT).ok_or_else(|| {
132 BitcoinCoreError::MissingHostPort {
133 container_id: container.id.clone(),
134 container_port: BITCOIND_P2P_PORT,
135 }
136 })?;
137 let rpc = BitcoinRpcClient::new("127.0.0.1", rpc_port, &auth.user, &auth.password);
138 let wallet_rpc = rpc.wallet(DEFAULT_BITCOIN_WALLET_NAME);
139
140 Ok(Self {
141 rpc_socket: format!("127.0.0.1:{rpc_port}"),
142 p2p_socket: format!("127.0.0.1:{p2p_port}"),
143 container,
144 auth,
145 rpc,
146 wallet_rpc,
147 })
148 }
149
150 pub async fn wait_ready(&self) -> Result<BlockchainInfo, BitcoinCoreError> {
152 self.wait_ready_with_policy(&RetryPolicy::default()).await
153 }
154
155 async fn wait_ready_with_policy(
156 &self,
157 policy: &RetryPolicy,
158 ) -> Result<BlockchainInfo, BitcoinCoreError> {
159 let mut last_error = None;
160
161 for _ in 0..policy.attempts {
162 match self.rpc.get_blockchain_info().await {
163 Ok(info) => return Ok(info),
164 Err(error) => {
165 last_error = Some(error);
166 sleep(policy.interval()).await;
167 }
168 }
169 }
170
171 Err(BitcoinCoreError::ReadyTimeout {
172 attempts: policy.attempts,
173 last_error: last_error.map(|error| error.to_string()),
174 })
175 }
176
177 pub async fn prepare_mining_wallet(&self) -> Result<Vec<String>, BitcoinCoreError> {
179 self.rpc
180 .ensure_wallet(DEFAULT_BITCOIN_WALLET_NAME)
181 .await
182 .map_err(BitcoinCoreError::BitcoinRpc)?;
183 let address = self
184 .wallet_rpc
185 .get_new_address()
186 .await
187 .map_err(BitcoinCoreError::BitcoinRpc)?;
188
189 self.rpc
190 .generate_to_address(DEFAULT_BITCOIN_WALLET_MATURITY_BLOCKS, &address)
191 .await
192 .map_err(BitcoinCoreError::BitcoinRpc)
193 }
194}
195
196#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
198pub struct BitcoinRpcAuth {
199 pub user: String,
201 pub password: String,
203 pub rpcauth: String,
205}
206
207impl BitcoinRpcAuth {
208 pub fn random() -> Self {
210 Self::random_with_user(DEFAULT_BITCOIN_RPC_USER)
211 }
212
213 pub fn random_with_user(user: impl Into<String>) -> Self {
215 let user = user.into();
216 let password = random_password();
217 let salt = hex::encode(rand::random::<[u8; 16]>());
218 let rpcauth = bitcoin_core_rpcauth(&user, &password, &salt);
219
220 Self {
221 user,
222 password,
223 rpcauth,
224 }
225 }
226}
227
228#[derive(Clone, Debug)]
230pub struct BitcoinRpcClient {
231 endpoint: String,
232 user: String,
233 password: String,
234 client: reqwest::Client,
235}
236
237impl BitcoinRpcClient {
238 pub fn new(
240 host: impl AsRef<str>,
241 port: u16,
242 user: impl Into<String>,
243 password: impl Into<String>,
244 ) -> Self {
245 Self {
246 endpoint: format!("http://{}:{port}/", host.as_ref()),
247 user: user.into(),
248 password: password.into(),
249 client: reqwest::Client::new(),
250 }
251 }
252
253 pub fn endpoint(&self) -> &str {
255 &self.endpoint
256 }
257
258 pub fn wallet(&self, wallet_name: &str) -> Self {
260 Self {
261 endpoint: format!(
262 "{}/wallet/{wallet_name}",
263 self.endpoint.trim_end_matches('/')
264 ),
265 user: self.user.clone(),
266 password: self.password.clone(),
267 client: self.client.clone(),
268 }
269 }
270
271 pub async fn get_blockchain_info(&self) -> Result<BlockchainInfo, BitcoinRpcError> {
273 self.call("getblockchaininfo", json!([])).await
274 }
275
276 pub async fn list_wallets(&self) -> Result<Vec<String>, BitcoinRpcError> {
278 self.call("listwallets", json!([])).await
279 }
280
281 pub async fn create_wallet(&self, wallet_name: &str) -> Result<CreateWallet, BitcoinRpcError> {
283 self.call("createwallet", json!([wallet_name])).await
284 }
285
286 pub async fn load_wallet(&self, wallet_name: &str) -> Result<LoadWallet, BitcoinRpcError> {
288 self.call("loadwallet", json!([wallet_name])).await
289 }
290
291 pub async fn ensure_wallet(&self, wallet_name: &str) -> Result<(), BitcoinRpcError> {
293 if self
294 .list_wallets()
295 .await?
296 .iter()
297 .any(|loaded| loaded == wallet_name)
298 {
299 return Ok(());
300 }
301
302 match self.load_wallet(wallet_name).await {
303 Ok(_) => Ok(()),
304 Err(BitcoinRpcError::Rpc { .. }) => {
305 self.create_wallet(wallet_name).await?;
306 Ok(())
307 }
308 Err(error) => Err(error),
309 }
310 }
311
312 pub async fn get_new_address(&self) -> Result<String, BitcoinRpcError> {
314 self.call("getnewaddress", json!([])).await
315 }
316
317 pub async fn generate_to_address(
319 &self,
320 count: u64,
321 address: &str,
322 ) -> Result<Vec<String>, BitcoinRpcError> {
323 self.call("generatetoaddress", json!([count, address]))
324 .await
325 }
326
327 pub async fn get_block(&self, hash: &str) -> Result<BlockInfo, BitcoinRpcError> {
329 self.call("getblock", json!([hash, 1])).await
330 }
331
332 pub async fn add_node(&self, socket: &str) -> Result<(), BitcoinRpcError> {
334 self.call_value("addnode", json!([socket, "add"])).await?;
335 Ok(())
336 }
337
338 pub async fn send_to_address(
340 &self,
341 address: &str,
342 amount_btc: f64,
343 ) -> Result<String, BitcoinRpcError> {
344 self.call("sendtoaddress", json!([address, amount_btc]))
345 .await
346 }
347
348 pub async fn send_many(
350 &self,
351 amounts: &std::collections::HashMap<String, f64>,
352 ) -> Result<String, BitcoinRpcError> {
353 self.call("sendmany", json!(["", amounts])).await
354 }
355
356 pub async fn call<T>(&self, method: &str, params: Value) -> Result<T, BitcoinRpcError>
358 where
359 T: DeserializeOwned,
360 {
361 let response = self.call_value(method, params).await?;
362
363 serde_json::from_value(response).map_err(|source| BitcoinRpcError::DecodeResult {
364 method: method.to_string(),
365 source,
366 })
367 }
368
369 pub async fn call_value(&self, method: &str, params: Value) -> Result<Value, BitcoinRpcError> {
371 let response = self
372 .client
373 .post(&self.endpoint)
374 .basic_auth(&self.user, Some(&self.password))
375 .json(&JsonRpcRequest {
376 jsonrpc: "1.0",
377 id: "spawn-lnd",
378 method,
379 params,
380 })
381 .send()
382 .await
383 .map_err(|source| BitcoinRpcError::Request {
384 method: method.to_string(),
385 source,
386 })?;
387
388 let status = response.status();
389 let body = response
390 .text()
391 .await
392 .map_err(|source| BitcoinRpcError::ReadBody {
393 method: method.to_string(),
394 source,
395 })?;
396
397 let response: JsonRpcResponse =
398 serde_json::from_str(&body).map_err(|source| BitcoinRpcError::Decode {
399 method: method.to_string(),
400 body,
401 source,
402 })?;
403
404 if let Some(error) = response.error {
405 return Err(BitcoinRpcError::Rpc {
406 method: method.to_string(),
407 code: error.code,
408 message: error.message,
409 });
410 }
411
412 if !status.is_success() {
413 return Err(BitcoinRpcError::HttpStatus {
414 method: method.to_string(),
415 status: status.as_u16(),
416 });
417 }
418
419 Ok(response.result)
420 }
421}
422
423#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
425pub struct BlockchainInfo {
426 pub chain: String,
428 pub blocks: u64,
430 pub headers: u64,
432 pub bestblockhash: String,
434}
435
436#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
438pub struct BlockInfo {
439 pub hash: String,
441 pub confirmations: Option<u64>,
443 pub height: Option<u64>,
445 pub tx: Vec<String>,
447}
448
449#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
451pub struct CreateWallet {
452 pub name: String,
454 #[serde(default)]
456 pub warning: String,
457}
458
459#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
461pub struct LoadWallet {
462 pub name: String,
464 #[serde(default)]
466 pub warning: String,
467}
468
469#[derive(Debug, Error)]
471pub enum BitcoinRpcError {
472 #[error("Bitcoin Core RPC request failed for method {method}")]
474 Request {
475 method: String,
477 source: reqwest::Error,
479 },
480
481 #[error("failed to read Bitcoin Core RPC response body for method {method}")]
483 ReadBody {
484 method: String,
486 source: reqwest::Error,
488 },
489
490 #[error("failed to decode Bitcoin Core RPC response for method {method}: {body}")]
492 Decode {
493 method: String,
495 body: String,
497 source: serde_json::Error,
499 },
500
501 #[error("failed to decode Bitcoin Core RPC result for method {method}")]
503 DecodeResult {
504 method: String,
506 source: serde_json::Error,
508 },
509
510 #[error("Bitcoin Core RPC method {method} returned HTTP status {status}")]
512 HttpStatus {
513 method: String,
515 status: u16,
517 },
518
519 #[error("Bitcoin Core RPC method {method} failed with code {code}: {message}")]
521 Rpc {
522 method: String,
524 code: i64,
526 message: String,
528 },
529}
530
531#[derive(Debug, Error)]
533pub enum BitcoinCoreError {
534 #[error(transparent)]
536 Docker(#[from] DockerError),
537
538 #[error("Docker container {container_id} did not publish expected port {container_port}")]
540 MissingHostPort {
541 container_id: String,
543 container_port: u16,
545 },
546
547 #[error(
549 "Bitcoin Core did not become ready after {attempts} attempts; last error: {last_error:?}"
550 )]
551 ReadyTimeout {
552 attempts: usize,
554 last_error: Option<String>,
556 },
557
558 #[error(transparent)]
560 BitcoinRpc(#[from] BitcoinRpcError),
561
562 #[error("Bitcoin Core startup failed for container {container_id}; logs: {logs:?}")]
564 Startup {
565 container_id: String,
567 logs: Option<String>,
569 source: Box<BitcoinCoreError>,
571 },
572}
573
574#[derive(Serialize)]
575struct JsonRpcRequest<'a> {
576 jsonrpc: &'a str,
577 id: &'a str,
578 method: &'a str,
579 params: Value,
580}
581
582#[derive(Deserialize)]
583struct JsonRpcResponse {
584 result: Value,
585 error: Option<JsonRpcErrorObject>,
586}
587
588#[derive(Deserialize)]
589struct JsonRpcErrorObject {
590 code: i64,
591 message: String,
592}
593
594pub fn bitcoin_core_rpcauth(user: &str, password: &str, salt: &str) -> String {
596 let hmac = bitcoin_core_auth_hmac(password, salt);
597 format!("{user}:{salt}${hmac}")
598}
599
600pub fn bitcoin_core_auth_hmac(password: &str, salt: &str) -> String {
602 let mut mac = HmacSha256::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length");
603 mac.update(password.as_bytes());
604 hex::encode(mac.finalize().into_bytes())
605}
606
607fn random_password() -> String {
608 URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
609}
610
611fn bitcoind_container_spec(config: &BitcoinCoreConfig, auth: &BitcoinRpcAuth) -> ContainerSpec {
612 let name = format!(
613 "spawn-lnd-{}-bitcoind-{}",
614 config.cluster_id, config.group_index
615 );
616 let labels = managed_container_labels(&config.cluster_id, ContainerRole::Bitcoind, None);
617
618 ContainerSpec::new(name, config.image.clone())
619 .cmd(bitcoind_args(auth))
620 .labels(labels)
621 .expose_ports([BITCOIND_RPC_PORT, BITCOIND_P2P_PORT])
622}
623
624fn bitcoind_args(auth: &BitcoinRpcAuth) -> Vec<String> {
625 vec![
626 "-regtest".to_string(),
627 "-printtoconsole".to_string(),
628 "-rpcbind=0.0.0.0".to_string(),
629 "-rpcallowip=0.0.0.0/0".to_string(),
630 "-fallbackfee=0.00001".to_string(),
631 "-server".to_string(),
632 "-txindex".to_string(),
633 "-blockfilterindex".to_string(),
634 "-coinstatsindex".to_string(),
635 format!("-rpcuser={}", auth.user),
636 format!("-rpcpassword={}", auth.password),
637 ]
638}
639
640#[cfg(test)]
641mod tests {
642 use super::{
643 BITCOIND_P2P_PORT, BITCOIND_RPC_PORT, BitcoinCoreConfig, BitcoinRpcAuth, BitcoinRpcClient,
644 DEFAULT_BITCOIN_RPC_USER, bitcoin_core_auth_hmac, bitcoin_core_rpcauth, bitcoind_args,
645 bitcoind_container_spec,
646 };
647 use crate::DEFAULT_BITCOIND_IMAGE;
648
649 #[test]
650 fn derives_bitcoin_core_auth_hmac() {
651 assert_eq!(
652 bitcoin_core_auth_hmac("password", "salt"),
653 "84ec44c7d6fc41917953a1dafca3c7d7856f7a9d0328b991b76f0d36be1224b9"
654 );
655 }
656
657 #[test]
658 fn derives_bitcoin_core_rpcauth() {
659 assert_eq!(
660 bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
661 "bitcoinrpc:salt$84ec44c7d6fc41917953a1dafca3c7d7856f7a9d0328b991b76f0d36be1224b9"
662 );
663 }
664
665 #[test]
666 fn random_auth_uses_default_user_and_rpcauth_shape() {
667 let auth = BitcoinRpcAuth::random();
668 let prefix = format!("{}:", DEFAULT_BITCOIN_RPC_USER);
669
670 assert_eq!(auth.user, DEFAULT_BITCOIN_RPC_USER);
671 assert!(!auth.password.is_empty());
672 assert!(auth.rpcauth.starts_with(&prefix));
673 assert!(auth.rpcauth.contains('$'));
674 }
675
676 #[test]
677 fn builds_rpc_endpoint() {
678 let client = BitcoinRpcClient::new("127.0.0.1", 18443, "user", "pass");
679
680 assert_eq!(client.endpoint(), "http://127.0.0.1:18443/");
681 }
682
683 #[test]
684 fn builds_wallet_rpc_endpoint() {
685 let client = BitcoinRpcClient::new("127.0.0.1", 18443, "user", "pass");
686 let wallet = client.wallet("spawn-lnd");
687
688 assert_eq!(wallet.endpoint(), "http://127.0.0.1:18443/wallet/spawn-lnd");
689 }
690
691 #[test]
692 fn default_bitcoin_core_config_uses_pinned_image() {
693 let config = BitcoinCoreConfig::new("cluster-1", 2);
694
695 assert_eq!(config.cluster_id, "cluster-1");
696 assert_eq!(config.group_index, 2);
697 assert_eq!(config.image, DEFAULT_BITCOIND_IMAGE);
698 }
699
700 #[test]
701 fn builds_bitcoind_regtest_args() {
702 let auth = BitcoinRpcAuth {
703 user: "bitcoinrpc".to_string(),
704 password: "password".to_string(),
705 rpcauth: bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
706 };
707
708 let args = bitcoind_args(&auth);
709
710 assert!(args.contains(&"-regtest".to_string()));
711 assert!(args.contains(&"-printtoconsole".to_string()));
712 assert!(args.contains(&"-rpcbind=0.0.0.0".to_string()));
713 assert!(args.contains(&"-rpcallowip=0.0.0.0/0".to_string()));
714 assert!(args.contains(&"-server".to_string()));
715 assert!(args.contains(&"-txindex".to_string()));
716 assert!(args.contains(&"-fallbackfee=0.00001".to_string()));
717 assert!(args.contains(&"-blockfilterindex".to_string()));
718 assert!(args.contains(&"-coinstatsindex".to_string()));
719 assert!(args.contains(&format!("-rpcuser={}", auth.user)));
720 assert!(args.contains(&format!("-rpcpassword={}", auth.password)));
721 }
722
723 #[test]
724 fn builds_bitcoind_container_spec() {
725 let config = BitcoinCoreConfig::new("cluster-1", 0);
726 let auth = BitcoinRpcAuth {
727 user: "bitcoinrpc".to_string(),
728 password: "password".to_string(),
729 rpcauth: bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
730 };
731
732 let spec = bitcoind_container_spec(&config, &auth);
733
734 assert_eq!(spec.name, "spawn-lnd-cluster-1-bitcoind-0");
735 assert_eq!(spec.image, DEFAULT_BITCOIND_IMAGE);
736 assert!(spec.exposed_ports.contains(&BITCOIND_RPC_PORT));
737 assert!(spec.exposed_ports.contains(&BITCOIND_P2P_PORT));
738 }
739}