node-app-build 6.12.1

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! 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";

/// Fixed container name. Without one the container is anonymous, so an
/// interrupted `up` (or a supervisor killed before it could `stop`) orphans a
/// container whose id is lost with the process — it keeps port 18443 and the
/// next `up` dies on a raw `port is already allocated` from docker with nothing
/// to act on. A stable name makes the orphan both findable and reusable.
pub const CONTAINER_NAME: &str = "node-app-harness-bitcoind";

/// 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)
            );
        }

        // Reclaim an already-running harness bitcoind before trying to start a
        // second one. Two checkouts share this container (regtest chain state
        // is disposable and the harness re-mines what it needs), so attaching
        // is correct — and it is the only way a run that was interrupted before
        // teardown can recover without the operator hunting the container down.
        if let Some(existing) = Self::running_harness_container()? {
            let node = Bitcoind {
                rpc_url: format!("http://127.0.0.1:{RPC_PORT}"),
                container_id: Some(existing),
            };
            if node.block_count().is_ok() {
                eprintln!(
                    "bitcoind: reusing running container '{CONTAINER_NAME}' on port {RPC_PORT}"
                );
                node.setup_chain()?;
                return Ok(node);
            }
            // Named container exists but its RPC is dead — remove it so the run
            // below gets a clean start rather than a name collision.
            eprintln!("bitcoind: removing unresponsive container '{CONTAINER_NAME}'");
            let _ = std::process::Command::new("docker")
                .args(["rm", "-f", CONTAINER_NAME])
                .output();
        }

        eprintln!("bitcoind: pulling/starting container ({IMAGE})…");

        let output = std::process::Command::new("docker")
            .args([
                "run",
                "-d",
                "--rm",
                "--name",
                CONTAINER_NAME,
                "-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);
            if stderr.contains("port is already allocated") {
                bail!(
                    "port {RPC_PORT} is already in use by something that is not a harness \
                     bitcoind (the harness container '{CONTAINER_NAME}' is not running).\n\
                     Common causes: Polar or a manual regtest bitcoind — attach to it with \
                     `--bitcoind external` instead.\n\
                     Inspect with: docker ps --filter publish={RPC_PORT}\n\
                     docker said: {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)
    }

    /// Container id of the running harness bitcoind, if any.
    fn running_harness_container() -> Result<Option<String>> {
        let out = std::process::Command::new("docker")
            .args([
                "ps",
                "--filter",
                &format!("name=^{CONTAINER_NAME}$"),
                "--format",
                "{{.ID}}",
            ])
            .output()
            .context("docker ps: failed to run docker")?;
        if !out.status.success() {
            return Ok(None);
        }
        let id = String::from_utf8_lossy(&out.stdout).trim().to_owned();
        Ok((!id.is_empty()).then_some(id))
    }

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