use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use crate::commands::harness::BitcoindMode;
pub const RPC_USER: &str = "polaruser";
pub const RPC_PASS: &str = "polarpass";
pub const RPC_PORT: u16 = 18443;
pub const IMAGE: &str = "lncm/bitcoind:v27.0";
const WALLET_NAME: &str = "e2e-wallet";
const STARTUP_TIMEOUT_SECS: u64 = 30;
pub struct Bitcoind {
pub rpc_url: String,
pub container_id: Option<String>,
}
impl Bitcoind {
pub fn from_state(s: &crate::commands::harness::state::BitcoindState) -> Self {
Bitcoind {
rpc_url: s.rpc_url.clone(),
container_id: s.container_id.clone(),
}
}
pub fn ensure(mode: BitcoindMode) -> Result<Self> {
match mode {
BitcoindMode::Docker => Self::ensure_docker(),
BitcoindMode::External => Self::ensure_external(),
}
}
pub fn mine(&self, blocks: u32) -> Result<()> {
let addr = self.get_new_address()?;
self.rpc_call("generatetoaddress", json!([blocks, addr]))?;
Ok(())
}
pub fn send_to_address(&self, addr: &str, btc: f64) -> Result<String> {
let result = self.rpc_call("sendtoaddress", json!([addr, btc]))?;
result
.as_str()
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("sendtoaddress: expected string txid, got: {result}"))
}
pub fn block_count(&self) -> Result<u64> {
let result = self.rpc_call("getblockcount", json!([]))?;
result
.as_u64()
.ok_or_else(|| anyhow::anyhow!("getblockcount: expected integer, got: {result}"))
}
pub fn stop(&self) -> Result<()> {
if let Some(id) = &self.container_id {
let status = std::process::Command::new("docker")
.args(["stop", id])
.status()
.context("docker stop: failed to run docker")?;
if !status.success() {
bail!("docker stop {id} exited with status {status}");
}
}
Ok(())
}
}
impl Bitcoind {
fn ensure_docker() -> Result<Self> {
let out = std::process::Command::new("docker")
.args(["version", "--format", "{{.Server.Version}}"])
.output()
.context("docker is not available on PATH")?;
if !out.status.success() {
bail!(
"docker daemon is not running: {}",
String::from_utf8_lossy(&out.stderr)
);
}
eprintln!("bitcoind: pulling/starting container ({IMAGE})…");
let output = std::process::Command::new("docker")
.args([
"run",
"-d",
"--rm",
"-p",
&format!("{RPC_PORT}:{RPC_PORT}"),
"-p",
"18444:18444",
IMAGE,
"-regtest",
&format!("-rpcuser={RPC_USER}"),
&format!("-rpcpassword={RPC_PASS}"),
"-rpcallowip=0.0.0.0/0",
"-rpcbind=0.0.0.0",
"-fallbackfee=0.00001",
])
.output()
.context("docker run: failed to start bitcoind container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("docker run bitcoind failed: {stderr}");
}
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if container_id.is_empty() {
bail!("docker run produced no container id on stdout");
}
eprintln!("bitcoind: container {container_id} started, waiting for RPC…");
let rpc_url = format!("http://127.0.0.1:{RPC_PORT}");
let node = Bitcoind {
rpc_url: rpc_url.clone(),
container_id: Some(container_id),
};
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs(STARTUP_TIMEOUT_SECS);
loop {
match node.block_count() {
Ok(_) => break,
Err(_) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(e) => {
let _ = node.stop();
bail!(
"bitcoind RPC did not become ready within {STARTUP_TIMEOUT_SECS}s: {e}"
);
}
}
}
eprintln!("bitcoind: RPC ready");
node.setup_chain()?;
Ok(node)
}
fn ensure_external() -> Result<Self> {
let rpc_url = format!("http://127.0.0.1:{RPC_PORT}");
let node = Bitcoind {
rpc_url: rpc_url.clone(),
container_id: None,
};
node.block_count().with_context(|| {
format!(
"bitcoind RPC at {rpc_url} is unreachable. Start Polar, or run a \
regtest bitcoind on port {RPC_PORT} with the polaruser credentials \
(see docs/development/node-app-harness.md)."
)
})?;
eprintln!("bitcoind: attached to external instance at {rpc_url}");
node.setup_chain()?;
Ok(node)
}
fn setup_chain(&self) -> Result<()> {
self.ensure_wallet()?;
let count = self.block_count()?;
if count == 0 {
eprintln!("bitcoind: mining 101 initial blocks to unlock coinbase funds…");
self.mine(101)?;
eprintln!("bitcoind: initial chain ready");
} else {
eprintln!("bitcoind: chain already at {count} blocks, skipping initial mining");
}
Ok(())
}
fn ensure_wallet(&self) -> Result<()> {
match self.rpc_call("createwallet", json!([WALLET_NAME])) {
Ok(_) => return Ok(()),
Err(e) => {
let msg = e.to_string();
let already_exists =
msg.contains("already exists") || msg.contains("Database already exists");
let already_loaded = msg.contains("already loaded");
if !already_exists && !already_loaded {
return Err(e).context("createwallet failed");
}
if already_loaded {
return Ok(());
}
}
}
match self.rpc_call("loadwallet", json!([WALLET_NAME])) {
Ok(_) => Ok(()),
Err(e) => {
if e.to_string().contains("already loaded") {
Ok(())
} else {
Err(e).context("loadwallet failed")
}
}
}
}
fn get_new_address(&self) -> Result<String> {
let result = self.rpc_call("getnewaddress", json!([]))?;
result
.as_str()
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("getnewaddress: expected string, got: {result}"))
}
fn rpc_call(&self, method: &str, params: Value) -> Result<Value> {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(120))
.build();
let body = json!({
"jsonrpc": "1.0",
"id": "harness",
"method": method,
"params": params,
});
let envelope: Value = match agent
.post(&self.rpc_url)
.set("Content-Type", "application/json")
.set(
"Authorization",
&format!(
"Basic {}",
base64_encode(&format!("{RPC_USER}:{RPC_PASS}"))
),
)
.send_json(body)
{
Ok(r) => r
.into_json()
.with_context(|| format!("parse JSON from bitcoind RPC {method}"))?,
Err(ureq::Error::Status(_code, r)) => r
.into_json()
.with_context(|| format!("parse JSON from bitcoind RPC {method} error body"))?,
Err(e) => bail!("bitcoind RPC {method} transport error: {e}"),
};
rpc_result(envelope)
}
}
pub(crate) fn rpc_result(envelope: Value) -> Result<Value> {
let error = envelope.get("error").cloned().unwrap_or(Value::Null);
if !error.is_null() {
let message = error
.get("message")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| error.to_string());
bail!("bitcoind RPC error: {message}");
}
envelope
.get("result")
.cloned()
.ok_or_else(|| anyhow::anyhow!("bitcoind RPC response missing 'result' field"))
}
fn base64_encode(input: &str) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = input.as_bytes();
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(TABLE[((n >> 18) & 63) as usize] as char);
out.push(TABLE[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(TABLE[((n >> 6) & 63) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(TABLE[(n & 63) as usize] as char);
} else {
out.push('=');
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_rpc_result_and_surfaces_error() {
let ok = serde_json::json!({ "result": 101, "error": null });
assert_eq!(rpc_result(ok).unwrap().as_u64(), Some(101));
let err = serde_json::json!({ "result": null, "error": { "code": -18, "message": "no wallet" } });
let e = rpc_result(err).unwrap_err().to_string();
assert!(e.contains("no wallet"), "got: {e}");
}
}