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 pub network: Option<String>,
43 pub ipv4_address: Option<String>,
45}
46
47impl BitcoinCoreConfig {
48 pub fn new(cluster_id: impl Into<String>, group_index: usize) -> Self {
50 Self {
51 cluster_id: cluster_id.into(),
52 group_index,
53 image: DEFAULT_BITCOIND_IMAGE.to_string(),
54 startup_retry: RetryPolicy::default(),
55 network: None,
56 ipv4_address: None,
57 }
58 }
59
60 pub fn image(mut self, image: impl Into<String>) -> Self {
62 self.image = image.into();
63 self
64 }
65
66 pub fn startup_retry_policy(mut self, policy: RetryPolicy) -> Self {
68 self.startup_retry = policy;
69 self
70 }
71
72 pub fn network(mut self, network: impl Into<String>) -> Self {
74 self.network = Some(network.into());
75 self
76 }
77
78 pub fn ipv4_address(mut self, ip: impl Into<String>) -> Self {
80 self.ipv4_address = Some(ip.into());
81 self
82 }
83}
84
85#[derive(Clone, Debug)]
87pub struct BitcoinCore {
88 pub container: SpawnedContainer,
90 pub auth: BitcoinRpcAuth,
92 pub rpc: BitcoinRpcClient,
94 pub wallet_rpc: BitcoinRpcClient,
96 pub rpc_socket: String,
98 pub p2p_socket: String,
100}
101
102impl BitcoinCore {
103 pub async fn spawn(
105 docker: &DockerClient,
106 config: BitcoinCoreConfig,
107 ) -> Result<Self, BitcoinCoreError> {
108 let auth = BitcoinRpcAuth::random();
109 let spec = bitcoind_container_spec(&config, &auth);
110 let container = docker.create_and_start(spec).await?;
111 let container_id = container.id.clone();
112 let core = match Self::from_container(container, auth) {
113 Ok(core) => core,
114 Err(error) => {
115 let logs = docker.container_logs(&container_id).await.ok();
116 let _ = docker.rollback_containers([container_id.clone()]).await;
117 return Err(BitcoinCoreError::Startup {
118 container_id,
119 logs,
120 source: Box::new(error),
121 });
122 }
123 };
124
125 if let Err(source) = core.wait_ready_with_policy(&config.startup_retry).await {
126 let logs = docker.container_logs(&core.container.id).await.ok();
127 let container_id = core.container.id.clone();
128 let _ = docker.rollback_containers([container_id.clone()]).await;
129 return Err(BitcoinCoreError::Startup {
130 container_id,
131 logs,
132 source: Box::new(source),
133 });
134 }
135
136 Ok(core)
137 }
138
139 fn from_container(
140 container: SpawnedContainer,
141 auth: BitcoinRpcAuth,
142 ) -> Result<Self, BitcoinCoreError> {
143 let rpc_port = container.host_port(BITCOIND_RPC_PORT).ok_or_else(|| {
144 BitcoinCoreError::MissingHostPort {
145 container_id: container.id.clone(),
146 container_port: BITCOIND_RPC_PORT,
147 }
148 })?;
149 let p2p_port = container.host_port(BITCOIND_P2P_PORT).ok_or_else(|| {
150 BitcoinCoreError::MissingHostPort {
151 container_id: container.id.clone(),
152 container_port: BITCOIND_P2P_PORT,
153 }
154 })?;
155 let rpc = BitcoinRpcClient::new("127.0.0.1", rpc_port, &auth.user, &auth.password);
156 let wallet_rpc = rpc.wallet(DEFAULT_BITCOIN_WALLET_NAME);
157
158 Ok(Self {
159 rpc_socket: format!("127.0.0.1:{rpc_port}"),
160 p2p_socket: format!("127.0.0.1:{p2p_port}"),
161 container,
162 auth,
163 rpc,
164 wallet_rpc,
165 })
166 }
167
168 fn refresh_from_container(
169 &mut self,
170 container: SpawnedContainer,
171 ) -> Result<(), BitcoinCoreError> {
172 let updated = Self::from_container(container, self.auth.clone())?;
173 *self = updated;
174 Ok(())
175 }
176
177 pub async fn stop(&self, docker: &DockerClient) -> Result<(), BitcoinCoreError> {
179 docker.stop_container(&self.container.id).await?;
180 Ok(())
181 }
182
183 pub async fn start(
185 &mut self,
186 docker: &DockerClient,
187 policy: &RetryPolicy,
188 ) -> Result<BlockchainInfo, BitcoinCoreError> {
189 let container = docker.start_container(&self.container.id).await?;
190 self.refresh_from_container(container)?;
191 self.wait_ready_with_policy(policy).await
192 }
193
194 pub async fn restart(
196 &mut self,
197 docker: &DockerClient,
198 policy: &RetryPolicy,
199 ) -> Result<BlockchainInfo, BitcoinCoreError> {
200 let container = docker.restart_container(&self.container.id).await?;
201 self.refresh_from_container(container)?;
202 self.wait_ready_with_policy(policy).await
203 }
204
205 pub async fn wait_ready(&self) -> Result<BlockchainInfo, BitcoinCoreError> {
207 self.wait_ready_with_policy(&RetryPolicy::default()).await
208 }
209
210 async fn wait_ready_with_policy(
211 &self,
212 policy: &RetryPolicy,
213 ) -> Result<BlockchainInfo, BitcoinCoreError> {
214 let mut last_error = None;
215
216 for _ in 0..policy.attempts {
217 match self.rpc.get_blockchain_info().await {
218 Ok(info) => return Ok(info),
219 Err(error) => {
220 last_error = Some(error);
221 sleep(policy.interval()).await;
222 }
223 }
224 }
225
226 Err(BitcoinCoreError::ReadyTimeout {
227 attempts: policy.attempts,
228 last_error: last_error.map(|error| error.to_string()),
229 })
230 }
231
232 pub async fn prepare_mining_wallet(&self) -> Result<Vec<String>, BitcoinCoreError> {
234 self.rpc
235 .ensure_wallet(DEFAULT_BITCOIN_WALLET_NAME)
236 .await
237 .map_err(BitcoinCoreError::BitcoinRpc)?;
238 let address = self
239 .wallet_rpc
240 .get_new_address()
241 .await
242 .map_err(BitcoinCoreError::BitcoinRpc)?;
243
244 self.rpc
245 .generate_to_address(DEFAULT_BITCOIN_WALLET_MATURITY_BLOCKS, &address)
246 .await
247 .map_err(BitcoinCoreError::BitcoinRpc)
248 }
249}
250
251#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
253pub struct BitcoinRpcAuth {
254 pub user: String,
256 pub password: String,
258 pub rpcauth: String,
260}
261
262impl BitcoinRpcAuth {
263 pub fn random() -> Self {
265 Self::random_with_user(DEFAULT_BITCOIN_RPC_USER)
266 }
267
268 pub fn random_with_user(user: impl Into<String>) -> Self {
270 let user = user.into();
271 let password = random_password();
272 let salt = hex::encode(rand::random::<[u8; 16]>());
273 let rpcauth = bitcoin_core_rpcauth(&user, &password, &salt);
274
275 Self {
276 user,
277 password,
278 rpcauth,
279 }
280 }
281}
282
283#[derive(Clone, Debug)]
285pub struct BitcoinRpcClient {
286 endpoint: String,
287 user: String,
288 password: String,
289 client: reqwest::Client,
290}
291
292impl BitcoinRpcClient {
293 pub fn new(
295 host: impl AsRef<str>,
296 port: u16,
297 user: impl Into<String>,
298 password: impl Into<String>,
299 ) -> Self {
300 Self {
301 endpoint: format!("http://{}:{port}/", host.as_ref()),
302 user: user.into(),
303 password: password.into(),
304 client: reqwest::Client::new(),
305 }
306 }
307
308 pub fn endpoint(&self) -> &str {
310 &self.endpoint
311 }
312
313 pub fn wallet(&self, wallet_name: &str) -> Self {
315 Self {
316 endpoint: format!(
317 "{}/wallet/{wallet_name}",
318 self.endpoint.trim_end_matches('/')
319 ),
320 user: self.user.clone(),
321 password: self.password.clone(),
322 client: self.client.clone(),
323 }
324 }
325
326 pub async fn get_blockchain_info(&self) -> Result<BlockchainInfo, BitcoinRpcError> {
328 self.call("getblockchaininfo", json!([])).await
329 }
330
331 pub async fn list_wallets(&self) -> Result<Vec<String>, BitcoinRpcError> {
333 self.call("listwallets", json!([])).await
334 }
335
336 pub async fn create_wallet(&self, wallet_name: &str) -> Result<CreateWallet, BitcoinRpcError> {
338 self.call("createwallet", json!([wallet_name])).await
339 }
340
341 pub async fn load_wallet(&self, wallet_name: &str) -> Result<LoadWallet, BitcoinRpcError> {
343 self.call("loadwallet", json!([wallet_name])).await
344 }
345
346 pub async fn ensure_wallet(&self, wallet_name: &str) -> Result<(), BitcoinRpcError> {
348 if self
349 .list_wallets()
350 .await?
351 .iter()
352 .any(|loaded| loaded == wallet_name)
353 {
354 return Ok(());
355 }
356
357 match self.load_wallet(wallet_name).await {
358 Ok(_) => Ok(()),
359 Err(BitcoinRpcError::Rpc { .. }) => {
360 self.create_wallet(wallet_name).await?;
361 Ok(())
362 }
363 Err(error) => Err(error),
364 }
365 }
366
367 pub async fn get_new_address(&self) -> Result<String, BitcoinRpcError> {
369 self.call("getnewaddress", json!([])).await
370 }
371
372 pub async fn generate_to_address(
374 &self,
375 count: u64,
376 address: &str,
377 ) -> Result<Vec<String>, BitcoinRpcError> {
378 self.call("generatetoaddress", json!([count, address]))
379 .await
380 }
381
382 pub async fn get_block(&self, hash: &str) -> Result<BlockInfo, BitcoinRpcError> {
384 self.call("getblock", json!([hash, 1])).await
385 }
386
387 pub async fn add_node(&self, socket: &str) -> Result<(), BitcoinRpcError> {
389 self.call_value("addnode", json!([socket, "add"])).await?;
390 Ok(())
391 }
392
393 pub async fn send_to_address(
395 &self,
396 address: &str,
397 amount_btc: f64,
398 ) -> Result<String, BitcoinRpcError> {
399 self.call("sendtoaddress", json!([address, amount_btc]))
400 .await
401 }
402
403 pub async fn send_many(
405 &self,
406 amounts: &std::collections::HashMap<String, f64>,
407 ) -> Result<String, BitcoinRpcError> {
408 self.call("sendmany", json!(["", amounts])).await
409 }
410
411 pub async fn call<T>(&self, method: &str, params: Value) -> Result<T, BitcoinRpcError>
413 where
414 T: DeserializeOwned,
415 {
416 let response = self.call_value(method, params).await?;
417
418 serde_json::from_value(response).map_err(|source| BitcoinRpcError::DecodeResult {
419 method: method.to_string(),
420 source,
421 })
422 }
423
424 pub async fn call_value(&self, method: &str, params: Value) -> Result<Value, BitcoinRpcError> {
426 let response = self
427 .client
428 .post(&self.endpoint)
429 .basic_auth(&self.user, Some(&self.password))
430 .json(&JsonRpcRequest {
431 jsonrpc: "1.0",
432 id: "spawn-lnd",
433 method,
434 params,
435 })
436 .send()
437 .await
438 .map_err(|source| BitcoinRpcError::Request {
439 method: method.to_string(),
440 source,
441 })?;
442
443 let status = response.status();
444 let body = response
445 .text()
446 .await
447 .map_err(|source| BitcoinRpcError::ReadBody {
448 method: method.to_string(),
449 source,
450 })?;
451
452 let response: JsonRpcResponse =
453 serde_json::from_str(&body).map_err(|source| BitcoinRpcError::Decode {
454 method: method.to_string(),
455 body,
456 source,
457 })?;
458
459 if let Some(error) = response.error {
460 return Err(BitcoinRpcError::Rpc {
461 method: method.to_string(),
462 code: error.code,
463 message: error.message,
464 });
465 }
466
467 if !status.is_success() {
468 return Err(BitcoinRpcError::HttpStatus {
469 method: method.to_string(),
470 status: status.as_u16(),
471 });
472 }
473
474 Ok(response.result)
475 }
476}
477
478#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
480pub struct BlockchainInfo {
481 pub chain: String,
483 pub blocks: u64,
485 pub headers: u64,
487 pub bestblockhash: String,
489}
490
491#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
493pub struct BlockInfo {
494 pub hash: String,
496 pub confirmations: Option<u64>,
498 pub height: Option<u64>,
500 pub tx: Vec<String>,
502}
503
504#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
506pub struct CreateWallet {
507 pub name: String,
509 #[serde(default)]
511 pub warning: String,
512}
513
514#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
516pub struct LoadWallet {
517 pub name: String,
519 #[serde(default)]
521 pub warning: String,
522}
523
524#[derive(Debug, Error)]
526pub enum BitcoinRpcError {
527 #[error("Bitcoin Core RPC request failed for method {method}")]
529 Request {
530 method: String,
532 source: reqwest::Error,
534 },
535
536 #[error("failed to read Bitcoin Core RPC response body for method {method}")]
538 ReadBody {
539 method: String,
541 source: reqwest::Error,
543 },
544
545 #[error("failed to decode Bitcoin Core RPC response for method {method}: {body}")]
547 Decode {
548 method: String,
550 body: String,
552 source: serde_json::Error,
554 },
555
556 #[error("failed to decode Bitcoin Core RPC result for method {method}")]
558 DecodeResult {
559 method: String,
561 source: serde_json::Error,
563 },
564
565 #[error("Bitcoin Core RPC method {method} returned HTTP status {status}")]
567 HttpStatus {
568 method: String,
570 status: u16,
572 },
573
574 #[error("Bitcoin Core RPC method {method} failed with code {code}: {message}")]
576 Rpc {
577 method: String,
579 code: i64,
581 message: String,
583 },
584}
585
586#[derive(Debug, Error)]
588pub enum BitcoinCoreError {
589 #[error(transparent)]
591 Docker(#[from] DockerError),
592
593 #[error("Docker container {container_id} did not publish expected port {container_port}")]
595 MissingHostPort {
596 container_id: String,
598 container_port: u16,
600 },
601
602 #[error(
604 "Bitcoin Core did not become ready after {attempts} attempts; last error: {last_error:?}"
605 )]
606 ReadyTimeout {
607 attempts: usize,
609 last_error: Option<String>,
611 },
612
613 #[error(transparent)]
615 BitcoinRpc(#[from] BitcoinRpcError),
616
617 #[error("Bitcoin Core startup failed for container {container_id}; logs: {logs:?}")]
619 Startup {
620 container_id: String,
622 logs: Option<String>,
624 source: Box<BitcoinCoreError>,
626 },
627}
628
629#[derive(Serialize)]
630struct JsonRpcRequest<'a> {
631 jsonrpc: &'a str,
632 id: &'a str,
633 method: &'a str,
634 params: Value,
635}
636
637#[derive(Deserialize)]
638struct JsonRpcResponse {
639 result: Value,
640 error: Option<JsonRpcErrorObject>,
641}
642
643#[derive(Deserialize)]
644struct JsonRpcErrorObject {
645 code: i64,
646 message: String,
647}
648
649pub fn bitcoin_core_rpcauth(user: &str, password: &str, salt: &str) -> String {
651 let hmac = bitcoin_core_auth_hmac(password, salt);
652 format!("{user}:{salt}${hmac}")
653}
654
655pub fn bitcoin_core_auth_hmac(password: &str, salt: &str) -> String {
657 let mut mac = HmacSha256::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length");
658 mac.update(password.as_bytes());
659 hex::encode(mac.finalize().into_bytes())
660}
661
662fn random_password() -> String {
663 URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
664}
665
666fn bitcoind_container_spec(config: &BitcoinCoreConfig, auth: &BitcoinRpcAuth) -> ContainerSpec {
667 let name = format!(
668 "spawn-lnd-{}-bitcoind-{}",
669 config.cluster_id, config.group_index
670 );
671 let labels = managed_container_labels(&config.cluster_id, ContainerRole::Bitcoind, None);
672
673 let mut spec = ContainerSpec::new(name, config.image.clone())
674 .cmd(bitcoind_args(auth))
675 .labels(labels)
676 .expose_ports([BITCOIND_RPC_PORT, BITCOIND_P2P_PORT]);
677
678 if let Some(network) = &config.network {
679 spec = spec.network(network.clone());
680 }
681 if let Some(ipv4_address) = &config.ipv4_address {
682 spec = spec.ipv4_address(ipv4_address.clone());
683 }
684
685 spec
686}
687
688fn bitcoind_args(auth: &BitcoinRpcAuth) -> Vec<String> {
689 vec![
690 "-regtest".to_string(),
691 "-printtoconsole".to_string(),
692 "-rpcbind=0.0.0.0".to_string(),
693 "-rpcallowip=0.0.0.0/0".to_string(),
694 "-fallbackfee=0.00001".to_string(),
695 "-server".to_string(),
696 "-txindex".to_string(),
697 "-blockfilterindex".to_string(),
698 "-coinstatsindex".to_string(),
699 format!("-rpcuser={}", auth.user),
700 format!("-rpcpassword={}", auth.password),
701 ]
702}
703
704#[cfg(test)]
705mod tests {
706 use super::{
707 BITCOIND_P2P_PORT, BITCOIND_RPC_PORT, BitcoinCoreConfig, BitcoinRpcAuth, BitcoinRpcClient,
708 DEFAULT_BITCOIN_RPC_USER, bitcoin_core_auth_hmac, bitcoin_core_rpcauth, bitcoind_args,
709 bitcoind_container_spec,
710 };
711 use crate::DEFAULT_BITCOIND_IMAGE;
712
713 #[test]
714 fn derives_bitcoin_core_auth_hmac() {
715 assert_eq!(
716 bitcoin_core_auth_hmac("password", "salt"),
717 "84ec44c7d6fc41917953a1dafca3c7d7856f7a9d0328b991b76f0d36be1224b9"
718 );
719 }
720
721 #[test]
722 fn derives_bitcoin_core_rpcauth() {
723 assert_eq!(
724 bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
725 "bitcoinrpc:salt$84ec44c7d6fc41917953a1dafca3c7d7856f7a9d0328b991b76f0d36be1224b9"
726 );
727 }
728
729 #[test]
730 fn random_auth_uses_default_user_and_rpcauth_shape() {
731 let auth = BitcoinRpcAuth::random();
732 let prefix = format!("{}:", DEFAULT_BITCOIN_RPC_USER);
733
734 assert_eq!(auth.user, DEFAULT_BITCOIN_RPC_USER);
735 assert!(!auth.password.is_empty());
736 assert!(auth.rpcauth.starts_with(&prefix));
737 assert!(auth.rpcauth.contains('$'));
738 }
739
740 #[test]
741 fn builds_rpc_endpoint() {
742 let client = BitcoinRpcClient::new("127.0.0.1", 18443, "user", "pass");
743
744 assert_eq!(client.endpoint(), "http://127.0.0.1:18443/");
745 }
746
747 #[test]
748 fn builds_wallet_rpc_endpoint() {
749 let client = BitcoinRpcClient::new("127.0.0.1", 18443, "user", "pass");
750 let wallet = client.wallet("spawn-lnd");
751
752 assert_eq!(wallet.endpoint(), "http://127.0.0.1:18443/wallet/spawn-lnd");
753 }
754
755 #[test]
756 fn default_bitcoin_core_config_uses_pinned_image() {
757 let config = BitcoinCoreConfig::new("cluster-1", 2);
758
759 assert_eq!(config.cluster_id, "cluster-1");
760 assert_eq!(config.group_index, 2);
761 assert_eq!(config.image, DEFAULT_BITCOIND_IMAGE);
762 }
763
764 #[test]
765 fn builds_bitcoind_regtest_args() {
766 let auth = BitcoinRpcAuth {
767 user: "bitcoinrpc".to_string(),
768 password: "password".to_string(),
769 rpcauth: bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
770 };
771
772 let args = bitcoind_args(&auth);
773
774 assert!(args.contains(&"-regtest".to_string()));
775 assert!(args.contains(&"-printtoconsole".to_string()));
776 assert!(args.contains(&"-rpcbind=0.0.0.0".to_string()));
777 assert!(args.contains(&"-rpcallowip=0.0.0.0/0".to_string()));
778 assert!(args.contains(&"-server".to_string()));
779 assert!(args.contains(&"-txindex".to_string()));
780 assert!(args.contains(&"-fallbackfee=0.00001".to_string()));
781 assert!(args.contains(&"-blockfilterindex".to_string()));
782 assert!(args.contains(&"-coinstatsindex".to_string()));
783 assert!(args.contains(&format!("-rpcuser={}", auth.user)));
784 assert!(args.contains(&format!("-rpcpassword={}", auth.password)));
785 }
786
787 #[test]
788 fn builds_bitcoind_container_spec() {
789 let config = BitcoinCoreConfig::new("cluster-1", 0);
790 let auth = BitcoinRpcAuth {
791 user: "bitcoinrpc".to_string(),
792 password: "password".to_string(),
793 rpcauth: bitcoin_core_rpcauth("bitcoinrpc", "password", "salt"),
794 };
795
796 let spec = bitcoind_container_spec(&config, &auth);
797
798 assert_eq!(spec.name, "spawn-lnd-cluster-1-bitcoind-0");
799 assert_eq!(spec.image, DEFAULT_BITCOIND_IMAGE);
800 assert!(spec.exposed_ports.contains(&BITCOIND_RPC_PORT));
801 assert!(spec.exposed_ports.contains(&BITCOIND_P2P_PORT));
802 }
803}