node-app-build 6.5.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! Regtest bitcoind lifecycle for the harness.
//!
//! Mirrors the RPC/wallet/mining sequence from
//! `tests/e2e/src/harness/bitcoind.ts`.

use anyhow::{bail, Context, Result};
use serde_json::{json, Value};

use crate::commands::harness::BitcoindMode;

// ─── Constants ───────────────────────────────────────────────────────────────

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";

/// Wallet name used for all mining / send operations.
const WALLET_NAME: &str = "e2e-wallet";

/// How many seconds to wait for the RPC to become responsive after starting
/// the Docker container.
const STARTUP_TIMEOUT_SECS: u64 = 30;

// ─── Public struct ────────────────────────────────────────────────────────────

/// A handle to a running (or externally-managed) regtest bitcoind.
pub struct Bitcoind {
    /// Full RPC URL, e.g. `http://127.0.0.1:18443`.
    pub rpc_url: String,
    /// Set when *we* started the container so `stop()` can kill it.
    pub container_id: Option<String>,
}

// ─── Core implementation ──────────────────────────────────────────────────────

impl Bitcoind {
    /// Reconstruct a `Bitcoind` handle from a persisted `BitcoindState`
    /// without re-running docker or probing the RPC.  Used by probe
    /// subcommands (mine, fund, channel-open, down) that read `harness-state.json`.
    pub fn from_state(s: &crate::commands::harness::state::BitcoindState) -> Self {
        Bitcoind {
            rpc_url: s.rpc_url.clone(),
            container_id: s.container_id.clone(),
        }
    }

    /// Bring up or attach to a bitcoind instance.
    ///
    /// `Docker`   — runs `docker run -d --rm …`, polls until the RPC
    ///              answers, ensures a wallet, and mines 101 blocks only if
    ///              the chain is empty (idempotent between harness runs).
    ///
    /// `External` — attaches to 127.0.0.1:18443 (Polar / manual bitcoind);
    ///              fails fast with a clear message if the RPC is unreachable.
    pub fn ensure(mode: BitcoindMode) -> Result<Self> {
        match mode {
            BitcoindMode::Docker => Self::ensure_docker(),
            BitcoindMode::External => Self::ensure_external(),
        }
    }

    /// Mine `blocks` regtest blocks by generating to a fresh address.
    pub fn mine(&self, blocks: u32) -> Result<()> {
        let addr = self.get_new_address()?;
        self.rpc_call("generatetoaddress", json!([blocks, addr]))?;
        Ok(())
    }

    /// Send `btc` BTC to `addr`; returns the txid.
    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}"))
    }

    /// Return the current block count.
    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}"))
    }

    /// Stop the bitcoind container (no-op for `External`).
    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(())
    }
}

// ─── Private helpers ──────────────────────────────────────────────────────────

impl Bitcoind {
    fn ensure_docker() -> Result<Self> {
        // Verify docker is available and its daemon is running.
        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),
        };

        // Poll until RPC answers or we time out.
        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) => {
                    // Best-effort cleanup.
                    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,
        };

        // Fail fast: probe once.
        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)
    }

    /// Ensure wallet and mine 101 blocks if the chain is empty (idempotent).
    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(())
    }

    /// Create the wallet if it doesn't exist yet; load it if it's already on
    /// disk; no-op if it's already loaded — mirrors `ensureWallet` in the TS
    /// harness.
    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");
                }
                // Already exists on disk — fall through to loadwallet.
                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}"))
    }

    /// Issue a JSON-RPC 1.0 call and return the `result` field.
    ///
    /// Uses `ureq` with Basic auth, following the style of
    /// `commands::dev::agent::client` (`post_json`).
    fn rpc_call(&self, method: &str, params: Value) -> Result<Value> {
        // Mining 101 blocks can take 10-15 seconds; use a generous timeout so
        // generatetoaddress and similar slow operations don't spuriously fail.
        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,
        });

        // bitcoind returns HTTP 500 for many application-level errors (wallet
        // already loaded, insufficient funds, etc.) while still sending a valid
        // JSON-RPC envelope.  Read the body regardless of HTTP status and let
        // `rpc_result` surface the JSON `error` field with the real message.
        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)
    }
}

// ─── RPC envelope splitter ────────────────────────────────────────────────────

/// Split a JSON-RPC 1.0 `{ "result": …, "error": … }` envelope.
///
/// Returns `Ok(result)` when `error` is null/absent; otherwise an `Err`
/// containing the error object's `message` field (falling back to the full
/// error JSON).
pub(crate) fn rpc_result(envelope: Value) -> Result<Value> {
    // Surface the error field first.
    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"))
}

// ─── Minimal Base-64 encoder (no extra dep) ──────────────────────────────────

/// Encode bytes as standard Base64. Used for the Basic Auth header.
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
}

// ─── Tests ────────────────────────────────────────────────────────────────────

#[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}");
    }
}