# Reliable address-index (electrs) setup Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the address index (electrs) work for any end user, report truthful status, surface real errors, and offer a one-click `bitcoin.conf` fix — instead of silently reporting "syncing" when electrs has crashed.

**Architecture:** electrs is launched and supervised by Rust (`electrs.rs`, `commands_online.rs`); all `bitcoin.conf` parsing/editing and auth resolution stay in Python (`online/setup.py`, exposed via `ui/server.py` endpoints) as the single source of truth; the Tauri UI (`tauri/src/app.js`) renders status from `get_electrs_status` and triggers fixes via new commands.

**Tech Stack:** Rust (Tauri 2, tokio), Python 3 stdlib (`unittest`, `http.server`), vanilla JS.

## Global Constraints

- Conf parsing/editing and auth resolution live in **Python**; Rust consumes only derived values (cookie path, port). The RPC password never crosses the localhost HTTP boundary.
- Synthesized cookie file path: `~/Armory/electrs/cookie`, file mode `0600`.
- The app never restarts the user's Bitcoin Core; it always instructs a manual restart.
- electrs auth is supplied via `--cookie-file <path>` only (this build has no inline user:pass option).
- The only required `bitcoin.conf` edit for electrs is `rest=1`; `txindex=1` is optional/opt-in.
- Python tests run with `python3 -m unittest`; Rust tests with `cargo test` (run via `source "$HOME/.cargo/env"`). No new dependencies.
- Follow existing code style; do not restructure unrelated files.

---

## Phase 1 — Truthful status + real errors (the critical bug)

### Task 1: Capture electrs stderr into a ring buffer

**Files:**
- Modify: `tauri/src-tauri/src/electrs.rs` (struct `ElectrsManager`, `new()`, `start()`)

**Interfaces:**
- Produces: `ElectrsManager.stderr_tail: Mutex<std::collections::VecDeque<String>>` and method `pub fn stderr_snapshot(&self) -> String` (joins the buffered lines with `\n`).

- [ ] **Step 1: Add the field and accessor**

In `electrs.rs`, add to the struct (after `install_status`):

```rust
pub struct ElectrsManager {
    pub process: Mutex<Option<tokio::process::Child>>,
    pub port: Mutex<u16>,
    pub db_dir: Mutex<PathBuf>,
    pub status: Mutex<ElectrsStatus>,
    pub install_status: Mutex<InstallStatus>,
    pub stderr_tail: Mutex<std::collections::VecDeque<String>>,
}
```

In `new()` add the initializer:

```rust
            install_status: Mutex::new(InstallStatus::default()),
            stderr_tail: Mutex::new(std::collections::VecDeque::with_capacity(64)),
```

Add the accessor inside `impl ElectrsManager` (near `get_status`):

```rust
    /// Last buffered electrs stderr lines, newest last, joined by newline.
    pub fn stderr_snapshot(&self) -> String {
        let buf = self.stderr_tail.lock().unwrap();
        buf.iter().cloned().collect::<Vec<_>>().join("\n")
    }
```

- [ ] **Step 2: Pipe stderr and stream it into the buffer in `start()`**

In `start()`, replace the spawn block. Change `.stderr(std::process::Stdio::null())` to piped, capture the handle, and clear the buffer on each (re)start:

```rust
        self.stderr_tail.lock().unwrap().clear();

        let mut child = tokio::process::Command::new(&electrs_path)
            .arg("--daemon-dir")
            .arg(&bitcoin_datadir)
            .arg("--db-dir")
            .arg(&db_dir)
            .arg("--electrum-rpc-addr")
            .arg(&listen_addr)
            .arg("--monitoring-addr")
            .arg("127.0.0.1:24225")
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::piped())
            .spawn()
            .map_err(|e| format!("Failed to start electrs: {}", e))?;
```

Note: this requires `child` to be `mut` and we take its stderr before storing it. Immediately after spawn, before `*self.process.lock()... = Some(child)`, add a reader task. Because `stderr_tail` must be shared, wrap the manager's buffer in an `Arc`? It already lives behind `&self`; instead pass a clone of an `Arc<Mutex<VecDeque<String>>>`. To avoid restructuring, store the buffer separately as `Arc`. Implement as follows — change the field type:

```rust
    pub stderr_tail: std::sync::Arc<Mutex<std::collections::VecDeque<String>>>,
```

`new()`:

```rust
            stderr_tail: std::sync::Arc::new(Mutex::new(std::collections::VecDeque::with_capacity(64))),
```

Then in `start()` after spawn:

```rust
        if let Some(stderr) = child.stderr.take() {
            let buf = self.stderr_tail.clone();
            tokio::spawn(async move {
                use tokio::io::{AsyncBufReadExt, BufReader};
                let mut lines = BufReader::new(stderr).lines();
                while let Ok(Some(line)) = lines.next_line().await {
                    let mut b = buf.lock().unwrap();
                    if b.len() == 64 { b.pop_front(); }
                    b.push_back(line);
                }
            });
        }
```

- [ ] **Step 3: Compile**

Run: `source "$HOME/.cargo/env" && cd tauri/src-tauri && cargo check --message-format=short`
Expected: compiles (warnings about unused `stderr_snapshot` are acceptable until Task 2).

- [ ] **Step 4: Commit**

```bash
git add tauri/src-tauri/src/electrs.rs
git commit -m "feat(electrs): capture electrs stderr into a ring buffer"
```

---

### Task 2: Map electrs stderr to a friendly error message

**Files:**
- Modify: `tauri/src-tauri/src/electrs.rs` (add pure helper + test module)

**Interfaces:**
- Produces: `pub fn map_electrs_error(stderr: &str) -> String`

- [ ] **Step 1: Write the failing tests**

Append to `electrs.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rest_404_maps_to_rest_hint() {
        let s = "ERROR bindex::client] GET http://localhost:8332/rest/blockhashbyheight/0.bin failed: StatusCode(404)";
        assert!(map_electrs_error(s).contains("rest=1"));
    }

    #[test]
    fn auth_401_maps_to_auth_hint() {
        let s = "request failed: http status: 401 Unauthorized";
        assert!(map_electrs_error(s).to_lowercase().contains("authenticate"));
    }

    #[test]
    fn connection_refused_maps_to_core_not_running() {
        let s = "tcp connect error: Connection refused (os error 61)";
        assert!(map_electrs_error(s).to_lowercase().contains("isn't running")
            || map_electrs_error(s).to_lowercase().contains("not running"));
    }

    #[test]
    fn unknown_falls_back_to_last_line() {
        let s = "some warning\nfinal meaningful line";
        assert_eq!(map_electrs_error(s), "final meaningful line");
    }

    #[test]
    fn empty_gives_generic() {
        assert!(!map_electrs_error("").is_empty());
    }
}
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `source "$HOME/.cargo/env" && cd tauri/src-tauri && cargo test map_electrs --message-format=short`
Expected: FAIL — `cannot find function map_electrs_error`.

- [ ] **Step 3: Implement the helper**

Add to `electrs.rs` (top-level, before the `tests` module):

```rust
/// Translate raw electrs stderr into a concise, actionable message.
pub fn map_electrs_error(stderr: &str) -> String {
    let low = stderr.to_lowercase();
    if low.contains("/rest/") || (low.contains("404") && low.contains("blockhash")) {
        return "Bitcoin Core's REST API is off — enable rest=1 in bitcoin.conf and restart Core.".to_string();
    }
    if low.contains("401") || low.contains("unauthorized") || low.contains("cookie") {
        return "electrs couldn't authenticate to Bitcoin Core. Check your RPC credentials or cookie.".to_string();
    }
    if low.contains("connection refused") || low.contains("os error 61") {
        return "Bitcoin Core isn't running or isn't reachable on its RPC port.".to_string();
    }
    let last = stderr.lines().rev().map(|l| l.trim()).find(|l| !l.is_empty());
    match last {
        Some(l) => l.to_string(),
        None => "Address indexer stopped unexpectedly.".to_string(),
    }
}
```

- [ ] **Step 4: Run tests, verify they pass**

Run: `source "$HOME/.cargo/env" && cd tauri/src-tauri && cargo test map_electrs --message-format=short`
Expected: PASS (5 tests).

- [ ] **Step 5: Commit**

```bash
git add tauri/src-tauri/src/electrs.rs
git commit -m "feat(electrs): map electrs stderr to friendly error messages"
```

---

### Task 3: Use the real error on process exit

**Files:**
- Modify: `tauri/src-tauri/src/commands_online.rs:596-634` (`get_electrs_status`)

**Interfaces:**
- Consumes: `ElectrsManager::stderr_snapshot`, `map_electrs_error`.

- [ ] **Step 1: Replace the generic exit error**

In `get_electrs_status`, change the `try_wait` branch:

```rust
                Ok(Some(_exit)) => {
                    *proc = None;
                    let raw = state.electrs.stderr_snapshot();
                    let mut s = state.electrs.status.lock().unwrap();
                    s.running = false;
                    s.indexing = false;
                    s.error = crate::electrs::map_electrs_error(&raw);
                }
```

- [ ] **Step 2: Compile**

Run: `source "$HOME/.cargo/env" && cd tauri/src-tauri && cargo check --message-format=short`
Expected: compiles cleanly.

- [ ] **Step 3: Commit**

```bash
git add tauri/src-tauri/src/commands_online.rs
git commit -m "feat(electrs): surface real crash reason in electrs status"
```

---

### Task 4: Render the real error in the UI; stop optimistic "started" / "syncing"

**Files:**
- Modify: `tauri/src/app.js:3517-3559` (electrs status rendering in `loadExplorer`)
- Modify: `tauri/src/app.js:3576-3586` (`startElectrs`)

**Interfaces:**
- Consumes: `get_electrs_status` JSON `{running, available, indexing, error, indexed_height, tip_height}`.

- [ ] **Step 1: Show the error and gate "syncing" on liveness**

Replace the electrs status block (`const es = await invoke('get_electrs_status');` through the closing of its `if/else if/else`) with:

```javascript
            const es = await invoke('get_electrs_status');
            if (es.error) {
                const isRest = /rest=1/i.test(es.error);
                const fixBtn = isRest
                    ? ` <button class="primary" onclick="fixBitcoinConf(['rest=1'])" style="margin-left:8px;padding:2px 10px;font-size:12px;border-radius:8px;">Fix bitcoin.conf for me</button>`
                    : ` <button class="secondary" onclick="startElectrs()" style="margin-left:8px;padding:2px 10px;font-size:12px;border-radius:7px;">Retry</button>`;
                electrsHtml = `<span style="color:var(--error);">Address index error: ${es.error}</span>${fixBtn}`;
            } else if (info.electrs === 'ready' || es.available) {
                electrsHtml = '<span style="color:var(--accent);">Address index: ready (instant lookups)</span>';
            } else if (es.running) {
                const idx = es.indexed_height || 0;
                const tip = info.blocks || 0;
                let progressHtml = '';
                if (tip > 0 && idx > 0) {
                    const pct = Math.min(100, (idx / tip) * 100);
                    const eta = calcElectrsEta(idx, tip);
                    progressHtml = `
                        <div style="margin-top:4px;">
                            <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-dim);margin-bottom:3px;">
                                <span>${idx.toLocaleString()} / ${tip.toLocaleString()} blocks indexed (${pct.toFixed(1)}%)</span>
                                <span>${eta}</span>
                            </div>
                            <div style="height:5px;background:var(--border);border-radius:3px;overflow:hidden;">
                                <div style="height:100%;width:${pct}%;background:var(--accent);border-radius:3px;transition:width 0.5s;"></div>
                            </div>
                        </div>`;
                }
                electrsHtml = `<span style="color:var(--warning);">Address index: syncing${tip > 0 && idx > 0 ? '' : ' — building index, lookups will be slow until it completes'}</span>${progressHtml}`;
                setTimeout(() => loadExplorer(true), 15000);
            } else {
                const det = await invoke('detect_electrs');
                if (det.installing) {
                    electrsHtml = `<span style="color:var(--warning);">${det.install_stage || 'Installing address indexer...'}</span>`;
                    setTimeout(() => loadExplorer(true), 10000);
                } else if (det.install_error) {
                    electrsHtml = `<span style="color:var(--error);">Install failed: ${det.install_error}</span> <button class="secondary" onclick="installElectrs()" style="margin-left:8px;padding:2px 10px;font-size:12px;border-radius:7px;">Retry</button>`;
                } else if (det.binary_found) {
                    electrsHtml = `<span style="color:var(--text-dim);">Address index: not running</span> <button class="secondary" onclick="startElectrs()" style="margin-left:8px;padding:2px 10px;font-size:12px;border-radius:7px;">Start</button>`;
                } else if (det.has_cargo) {
                    electrsHtml = `<span style="color:var(--text-dim);">Address index: not set up</span> <button class="primary" onclick="installElectrs()" style="margin-left:8px;padding:2px 10px;font-size:12px;border-radius:8px;">Build address index</button> <span style="color:var(--text-dim);font-size:11px;margin-left:4px;">Enables instant address lookups (runs 100% locally)</span>`;
                } else {
                    electrsHtml = '<span style="color:var(--text-dim);">Address index: not available — Rust toolchain required. Install from <a href="https://rustup.rs" style="color:var(--accent);">rustup.rs</a>, then restart.</span>';
                }
            }
```

- [ ] **Step 2: Make `startElectrs` poll for real status instead of claiming success**

Replace `startElectrs`:

```javascript
async function startElectrs() {
    const alertDiv = document.getElementById('explorer-alert');
    try {
        await invoke('start_electrs');
        alertDiv.innerHTML = '<div style="color:var(--text-dim);font-size:13px;">Starting address indexer…</div>';
        // Re-check shortly: if it crashed on startup, the status now carries the real error.
        setTimeout(() => loadExplorer(true), 2500);
    } catch (e) {
        alertDiv.innerHTML = `<div class="alert error">${e}</div>`;
    }
}
```

- [ ] **Step 3: Syntax check**

Run: `node --check tauri/src/app.js`
Expected: no output (OK). (`fixBitcoinConf` is defined in Phase 3; until then the Retry path and all other branches work. If verifying Phase 1 in isolation, the rest=1 button will no-op — acceptable, error text still shows.)

- [ ] **Step 4: Commit**

```bash
git add tauri/src/app.js
git commit -m "feat(explorer): show real electrs error, stop false 'syncing'/'started'"
```

---

### Task 5: Manual verification of Phase 1

- [ ] **Step 1: Rebuild and reproduce**

Rebuild the app (the Python/JS/Rust changes must be bundled). With a node that lacks `rest=1`, click Start in Block Explorer.
Expected: instead of "syncing", the UI shows
`Address index error: Bitcoin Core's REST API is off — enable rest=1 in bitcoin.conf and restart Core.`
and electrs is confirmed not running (`pgrep -lf electrs` → nothing, `~/Armory/electrs-db` stays ~0 B).

- [ ] **Step 2: Confirm no false-positive**

Verify the status does not read "syncing" or "ready" while `pgrep -lf electrs` shows no process.

---

## Phase 2 — Make electrs actually connect (auth, port, pre-flight)

### Task 6: Python `ensure_electrs_cookie`

**Files:**
- Modify: `online/setup.py` (add helper near `_read_bitcoin_conf`)
- Test: `tests/test_electrs_setup.py` (new)

**Interfaces:**
- Consumes: `_read_bitcoin_conf(datadir)` (exists, datadir-aware).
- Produces: `ensure_electrs_cookie(datadir: Optional[str]) -> Optional[str]`.

- [ ] **Step 1: Write the failing tests**

Create `tests/test_electrs_setup.py`:

```python
"""Tests for electrs cookie synthesis and bitcoin.conf editing."""

import os
import stat
import tempfile
import unittest
from unittest import mock

from online import setup as node_setup


class EnsureElectrsCookieTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.datadir = self.tmp.name
        self.home = tempfile.TemporaryDirectory()
        self._p = mock.patch.dict(os.environ, {"HOME": self.home.name, "APPDATA": ""})
        self._p.start()

    def tearDown(self):
        self._p.stop()
        self.tmp.cleanup()
        self.home.cleanup()

    def test_returns_existing_cookie_when_present(self):
        cookie = os.path.join(self.datadir, ".cookie")
        with open(cookie, "w") as f:
            f.write("__cookie__:abc")
        self.assertEqual(node_setup.ensure_electrs_cookie(self.datadir), cookie)

    def test_synthesizes_cookie_from_user_password(self):
        with open(os.path.join(self.datadir, "bitcoin.conf"), "w") as f:
            f.write("rpcuser=alice\nrpcpassword=s3cret\n")
        path = node_setup.ensure_electrs_cookie(self.datadir)
        self.assertIsNotNone(path)
        with open(path) as f:
            self.assertEqual(f.read(), "alice:s3cret")
        mode = stat.S_IMODE(os.stat(path).st_mode)
        self.assertEqual(mode, 0o600)

    def test_returns_none_when_no_auth(self):
        self.assertIsNone(node_setup.ensure_electrs_cookie(self.datadir))
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `python3 -m unittest tests.test_electrs_setup -v`
Expected: FAIL — `module 'online.setup' has no attribute 'ensure_electrs_cookie'`.

- [ ] **Step 3: Implement**

Add to `online/setup.py` (after `describe_node_auth`):

```python
ELECTRS_COOKIE_PATH = os.path.expanduser("~/Armory/electrs/cookie")


def ensure_electrs_cookie(datadir: Optional[str] = None) -> Optional[str]:
    """Return a cookie file path electrs can use for RPC auth.

    Prefers Core's own .cookie in the datadir; otherwise synthesizes one from
    rpcuser/rpcpassword (mode 0600). Returns None if no auth is available."""
    if datadir:
        existing = os.path.join(os.path.expanduser(datadir), ".cookie")
        if os.path.exists(existing):
            return existing
    user, password, _port = _read_bitcoin_conf(datadir)
    if user and password:
        os.makedirs(os.path.dirname(ELECTRS_COOKIE_PATH), exist_ok=True)
        with open(ELECTRS_COOKIE_PATH, "w") as f:
            f.write(f"{user}:{password}")
        os.chmod(ELECTRS_COOKIE_PATH, 0o600)
        return ELECTRS_COOKIE_PATH
    return None
```

- [ ] **Step 4: Run tests, verify they pass**

Run: `python3 -m unittest tests.test_electrs_setup -v`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add online/setup.py tests/test_electrs_setup.py
git commit -m "feat(electrs): synthesize cookie file for user/password auth"
```

---

### Task 7: Python `electrs_preflight` + endpoints

**Files:**
- Modify: `online/setup.py` (add `electrs_preflight`)
- Modify: `ui/server.py` (import + two GET routes)
- Test: `tests/test_electrs_setup.py` (extend)

**Interfaces:**
- Consumes: `ensure_electrs_cookie`, `_read_bitcoin_conf`.
- Produces:
  - `electrs_preflight(datadir, rpc_port, rest_ok) -> dict` returning `{"ok": bool, "needs": "rest"|"auth"|None, "cookie_path": str|None}`. `rest_ok` is injected (whether a REST probe succeeded) so the function stays pure/testable; the HTTP probe is done by the caller.
  - `GET /api/electrs-cookie` → `{"cookie_path": str|null}`
  - `GET /api/electrs-preflight` → full preflight dict (server performs the REST probe).

- [ ] **Step 1: Write the failing tests (pure function)**

Append to `tests/test_electrs_setup.py`:

```python
class ElectrsPreflightTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.datadir = self.tmp.name
        self.home = tempfile.TemporaryDirectory()
        self._p = mock.patch.dict(os.environ, {"HOME": self.home.name, "APPDATA": ""})
        self._p.start()

    def tearDown(self):
        self._p.stop()
        self.tmp.cleanup()
        self.home.cleanup()

    def test_needs_rest_when_rest_probe_failed(self):
        with open(os.path.join(self.datadir, ".cookie"), "w") as f:
            f.write("__cookie__:x")
        r = node_setup.electrs_preflight(self.datadir, 8332, rest_ok=False)
        self.assertFalse(r["ok"])
        self.assertEqual(r["needs"], "rest")

    def test_needs_auth_when_no_cookie_and_no_creds(self):
        r = node_setup.electrs_preflight(self.datadir, 8332, rest_ok=True)
        self.assertFalse(r["ok"])
        self.assertEqual(r["needs"], "auth")

    def test_ok_when_rest_and_cookie(self):
        with open(os.path.join(self.datadir, ".cookie"), "w") as f:
            f.write("__cookie__:x")
        r = node_setup.electrs_preflight(self.datadir, 8332, rest_ok=True)
        self.assertTrue(r["ok"])
        self.assertIsNone(r["needs"])
        self.assertTrue(r["cookie_path"].endswith(".cookie"))
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `python3 -m unittest tests.test_electrs_setup.ElectrsPreflightTests -v`
Expected: FAIL — no attribute `electrs_preflight`.

- [ ] **Step 3: Implement the pure function**

Add to `online/setup.py`:

```python
def electrs_preflight(datadir: Optional[str], rpc_port: Optional[int], rest_ok: bool) -> Dict:
    """Decide whether electrs can start. `rest_ok` is the result of the caller's
    REST probe. Returns {ok, needs, cookie_path}."""
    if not rest_ok:
        return {"ok": False, "needs": "rest", "cookie_path": None}
    cookie = ensure_electrs_cookie(datadir)
    if not cookie:
        return {"ok": False, "needs": "auth", "cookie_path": None}
    return {"ok": True, "needs": None, "cookie_path": cookie}
```

- [ ] **Step 4: Run tests, verify they pass**

Run: `python3 -m unittest tests.test_electrs_setup.ElectrsPreflightTests -v`
Expected: PASS (3 tests).

- [ ] **Step 5: Add the server routes (with the REST probe)**

In `ui/server.py`, extend the import:

```python
from online.setup import OnlineApp, describe_node_auth, ensure_electrs_cookie, electrs_preflight
```

Add a REST-probe helper near the top of the handler class (after `_send_json`):

```python
    def _probe_core_rest(self, port):
        import http.client
        try:
            conn = http.client.HTTPConnection("127.0.0.1", port, timeout=4)
            conn.request("GET", "/rest/chaininfo.json")
            resp = conn.getresponse()
            resp.read()
            return resp.status == 200
        except Exception:
            return False
```

In `_route_api_get`, add routes (after the `/api/probe-datadir` route from the earlier work):

```python
        elif path == "/api/electrs-cookie":
            app = get_app()
            self._send_json({"cookie_path": ensure_electrs_cookie(app.datadir)})
        elif path == "/api/electrs-preflight":
            app = get_app()
            port = app.rpc_port or 8332
            rest_ok = self._probe_core_rest(port)
            self._send_json(electrs_preflight(app.datadir, port, rest_ok))
```

(`app.datadir` and `app.rpc_port` are existing `OnlineApp` attributes — `online/setup.py:633,636`.)

- [ ] **Step 6: Verify import + endpoints load**

Run: `python3 -c "import ui.server; from online.setup import electrs_preflight, ensure_electrs_cookie; print('ok')"`
Expected: prints `ok`.

- [ ] **Step 7: Commit**

```bash
git add online/setup.py ui/server.py tests/test_electrs_setup.py
git commit -m "feat(electrs): preflight check + cookie/preflight endpoints"
```

---

### Task 8: Pass cookie-file + RPC port to electrs; preflight before spawn

**Files:**
- Modify: `tauri/src-tauri/src/electrs.rs` (`start` signature)
- Modify: `tauri/src-tauri/src/commands_online.rs` (`start_electrs`, install auto-start)

**Interfaces:**
- Consumes: `/api/electrs-preflight` (`{ok, needs, cookie_path}`), `effective_bitcoin_datadir`.
- Produces: `ElectrsManager::start(&self, electrs_path: PathBuf, bitcoin_datadir: PathBuf, daemon_rpc_port: u16, cookie_file: Option<PathBuf>)`.

- [ ] **Step 1: Extend `start()` to use port + cookie-file**

In `electrs.rs`, change the signature and the command build:

```rust
    pub fn start(&self, electrs_path: PathBuf, bitcoin_datadir: PathBuf,
                 daemon_rpc_port: u16, cookie_file: Option<PathBuf>) -> Result<(), String> {
        self.stop()?;
        // ... existing db_dir setup ...
        let daemon_rpc_addr = format!("127.0.0.1:{}", daemon_rpc_port);

        self.stderr_tail.lock().unwrap().clear();
        let mut cmd = tokio::process::Command::new(&electrs_path);
        cmd.arg("--daemon-dir").arg(&bitcoin_datadir)
            .arg("--daemon-rpc-addr").arg(&daemon_rpc_addr)
            .arg("--db-dir").arg(&db_dir)
            .arg("--electrum-rpc-addr").arg(&listen_addr)
            .arg("--monitoring-addr").arg("127.0.0.1:24225");
        if let Some(ref cf) = cookie_file {
            cmd.arg("--cookie-file").arg(cf);
        }
        let mut child = cmd
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::piped())
            .spawn()
            .map_err(|e| format!("Failed to start electrs: {}", e))?;
        // ... stderr reader task from Task 1 ...
        // ... store process, set status.running = true ...
    }
```

- [ ] **Step 2: Update `start_electrs` to preflight and pass new args**

In `commands_online.rs`, replace the body of `start_electrs`:

```rust
pub async fn start_electrs(state: State<'_, OnlineState>, app: tauri::AppHandle) -> Result<Value, String> {
    let resource_dir = app.path().resource_dir().unwrap_or_default();
    let binary = ElectrsManager::find_binary(&resource_dir)
        .ok_or("Electrs binary not found")?;
    let configured_datadir = state.datadir.lock().unwrap().clone();
    let datadir = effective_bitcoin_datadir(configured_datadir)
        .ok_or("Bitcoin Core data directory not found")?;

    let pre = api_get(&state, "/api/electrs-preflight").await?;
    if pre.get("ok").and_then(|v| v.as_bool()) != Some(true) {
        let needs = pre.get("needs").and_then(|v| v.as_str()).unwrap_or("");
        let msg = match needs {
            "rest" => "Bitcoin Core's REST API is off — enable rest=1 in bitcoin.conf and restart Core.",
            "auth" => "No RPC cookie or credentials found for electrs. Configure rpcuser/rpcpassword or enable cookie auth.",
            _ => "Bitcoin Core is not ready for the address index.",
        };
        // Record as electrs error so the UI renders it consistently.
        let mut s = state.electrs.status.lock().unwrap();
        s.running = false;
        s.error = msg.to_string();
        return Ok(serde_json::json!({"started": false, "needs": needs}));
    }
    let cookie_file = pre.get("cookie_path").and_then(|v| v.as_str())
        .map(std::path::PathBuf::from);
    let port = state.rpc_port.lock().unwrap().unwrap_or(8332);
    state.electrs.start(binary, datadir, port, cookie_file)?;
    Ok(serde_json::json!({"started": true}))
}
```

- [ ] **Step 3: Update the install auto-start call site**

In `install_electrs` auto-start block, replace the `effective_bitcoin_datadir(...)` start with the new signature (best-effort; auth via api is not available in the thread, so synthesize via default cookie path detection):

```rust
                let configured_datadir = electrs_state.datadir.lock().unwrap().clone();
                let port = electrs_state.rpc_port.lock().unwrap().unwrap_or(8332);
                if let (Some(bin), Some(datadir)) = (
                    ElectrsManager::find_binary(&resource_dir),
                    effective_bitcoin_datadir(configured_datadir.clone()),
                ) {
                    let cookie = configured_datadir
                        .map(|d| std::path::PathBuf::from(d).join(".cookie"))
                        .filter(|p| p.exists());
                    let _ = electrs_state.electrs.start(bin, datadir, port, cookie);
                }
```

(The user can click Start afterward to run the full preflight path if auto-start can't authenticate.)

- [ ] **Step 4: Compile**

Run: `source "$HOME/.cargo/env" && cd tauri/src-tauri && cargo check --message-format=short`
Expected: compiles.

- [ ] **Step 5: Commit**

```bash
git add tauri/src-tauri/src/electrs.rs tauri/src-tauri/src/commands_online.rs
git commit -m "feat(electrs): preflight before start; pass cookie-file and rpc port"
```

---

## Phase 3 — Auto-fix bitcoin.conf button (+ optional txindex)

### Task 9: Python `set_conf_option`

**Files:**
- Modify: `online/setup.py`
- Test: `tests/test_electrs_setup.py` (extend)

**Interfaces:**
- Produces: `set_conf_option(datadir: Optional[str], key: str, value: str) -> Dict` returning `{"action": "created"|"added"|"updated"|"unchanged", "conf_path": str, "backup": str|None}`.

- [ ] **Step 1: Write the failing tests**

Append to `tests/test_electrs_setup.py`:

```python
class SetConfOptionTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.datadir = self.tmp.name
        self.conf = os.path.join(self.datadir, "bitcoin.conf")

    def tearDown(self):
        self.tmp.cleanup()

    def test_adds_key_to_existing_conf_and_backs_up(self):
        with open(self.conf, "w") as f:
            f.write("server=1\n")
        r = node_setup.set_conf_option(self.datadir, "rest", "1")
        self.assertEqual(r["action"], "added")
        self.assertTrue(os.path.exists(r["backup"]))
        with open(self.conf) as f:
            body = f.read()
        self.assertIn("server=1", body)
        self.assertIn("rest=1", body)

    def test_unchanged_when_already_set(self):
        with open(self.conf, "w") as f:
            f.write("rest=1\n")
        r = node_setup.set_conf_option(self.datadir, "rest", "1")
        self.assertEqual(r["action"], "unchanged")

    def test_updates_existing_different_value(self):
        with open(self.conf, "w") as f:
            f.write("rest=0\n")
        r = node_setup.set_conf_option(self.datadir, "rest", "1")
        self.assertEqual(r["action"], "updated")
        with open(self.conf) as f:
            self.assertIn("rest=1", f.read())

    def test_creates_conf_when_missing(self):
        r = node_setup.set_conf_option(self.datadir, "rest", "1")
        self.assertEqual(r["action"], "created")
        with open(self.conf) as f:
            self.assertIn("rest=1", f.read())
```

- [ ] **Step 2: Run tests, verify they fail**

Run: `python3 -m unittest tests.test_electrs_setup.SetConfOptionTests -v`
Expected: FAIL — no attribute `set_conf_option`.

- [ ] **Step 3: Implement**

Add to `online/setup.py`:

```python
def _primary_conf_path(datadir: Optional[str]) -> str:
    if datadir:
        return os.path.join(os.path.expanduser(datadir), "bitcoin.conf")
    for cand in _bitcoin_conf_candidates(None):
        if os.path.exists(cand):
            return cand
    return os.path.expanduser("~/Library/Application Support/Bitcoin/bitcoin.conf")


def set_conf_option(datadir: Optional[str], key: str, value: str) -> Dict:
    """Idempotently set key=value in bitcoin.conf, backing up first. Preserves
    comments and other lines. Returns a summary of the change."""
    conf_path = _primary_conf_path(datadir)
    if not os.path.exists(conf_path):
        os.makedirs(os.path.dirname(conf_path), exist_ok=True)
        with open(conf_path, "w") as f:
            f.write(f"{key}={value}\n")
        return {"action": "created", "conf_path": conf_path, "backup": None}

    with open(conf_path, "r") as f:
        lines = f.readlines()

    found = False
    action = "added"
    for i, line in enumerate(lines):
        stripped = line.strip()
        if stripped.startswith("#") or "=" not in stripped:
            continue
        k, _, v = stripped.partition("=")
        if k.strip() == key:
            found = True
            if v.strip() == value:
                return {"action": "unchanged", "conf_path": conf_path, "backup": None}
            lines[i] = f"{key}={value}\n"
            action = "updated"
            break

    backup = conf_path + ".bak"
    with open(backup, "w") as f:
        f.writelines(open(conf_path).readlines())

    if not found:
        if lines and not lines[-1].endswith("\n"):
            lines[-1] += "\n"
        lines.append(f"{key}={value}\n")

    tmp = conf_path + ".tmp"
    with open(tmp, "w") as f:
        f.writelines(lines)
    os.replace(tmp, conf_path)
    return {"action": action, "conf_path": conf_path, "backup": backup}
```

- [ ] **Step 4: Run tests, verify they pass**

Run: `python3 -m unittest tests.test_electrs_setup.SetConfOptionTests -v`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add online/setup.py tests/test_electrs_setup.py
git commit -m "feat(conf): idempotent set_conf_option with backup"
```

---

### Task 10: `fix-conf` endpoint + Tauri command

**Files:**
- Modify: `ui/server.py` (POST route)
- Modify: `tauri/src-tauri/src/commands_online.rs` (new command)
- Modify: `tauri/src-tauri/src/main.rs` (register command)

**Interfaces:**
- Produces:
  - `POST /api/fix-conf` body `{"options": ["rest=1", ...]}` → `{"results": [<set_conf_option dicts>]}`
  - Tauri `fix_bitcoin_conf(options: Vec<String>) -> Result<Value, String>`

- [ ] **Step 1: Add the POST route + handler**

POST routing in `ui/server.py` goes through `_route_api_post(path)`, and handlers read the body with `self._read_body()` (returns a parsed dict). Add the route in `_route_api_post`:

```python
        elif path == "/api/fix-conf":
            self._api_fix_conf()
```

Add the handler method (near `_api_broadcast`):

```python
    def _api_fix_conf(self):
        app = get_app()
        data = self._read_body()
        results = []
        for opt in data.get("options", []):
            if "=" in opt:
                k, _, v = opt.partition("=")
                results.append(set_conf_option(app.datadir, k.strip(), v.strip()))
        self._send_json({"results": results})
```

Extend the existing top-of-file import to include `set_conf_option`:

```python
from online.setup import (OnlineApp, describe_node_auth, ensure_electrs_cookie,
                          electrs_preflight, set_conf_option)
```

- [ ] **Step 2: Add the Tauri command**

In `commands_online.rs`:

```rust
#[tauri::command]
pub async fn fix_bitcoin_conf(state: State<'_, OnlineState>, options: Vec<String>) -> Result<Value, String> {
    let body = serde_json::json!({ "options": options });
    api_post(&state, "/api/fix-conf", &body).await
}
```

Register in `main.rs` (after `probe_node_datadir`):

```rust
            commands_online::fix_bitcoin_conf,
```

- [ ] **Step 3: Compile + import check**

Run: `python3 -c "import ui.server; print('ok')"`
Expected: `ok`.
Run: `source "$HOME/.cargo/env" && cd tauri/src-tauri && cargo check --message-format=short`
Expected: compiles.

- [ ] **Step 4: Commit**

```bash
git add ui/server.py tauri/src-tauri/src/commands_online.rs tauri/src-tauri/src/main.rs
git commit -m "feat(conf): fix-conf endpoint and tauri command"
```

---

### Task 11: Wire the UI buttons (rest=1 + txindex)

**Files:**
- Modify: `tauri/src/app.js` (add `fixBitcoinConf`; add txindex button to the explorer txindex line)

**Interfaces:**
- Consumes: Tauri `fix_bitcoin_conf`.

- [ ] **Step 1: Add the `fixBitcoinConf` helper**

Add near `startElectrs` in `app.js`:

```javascript
async function fixBitcoinConf(options, opts = {}) {
    const alertDiv = document.getElementById('explorer-alert');
    if (opts.confirm && !confirm(opts.confirm)) return;
    try {
        const res = await invoke('fix_bitcoin_conf', { options });
        const changed = (res.results || []).some(r => r.action !== 'unchanged');
        alertDiv.innerHTML = changed
            ? `<div style="color:var(--accent);font-size:13px;">Updated bitcoin.conf (${options.join(', ')}). <strong>Now quit and restart Bitcoin Core</strong>, then come back.</div>`
            : `<div style="color:var(--text-dim);font-size:13px;">bitcoin.conf already had ${options.join(', ')}. If it still isn't working, make sure you restarted Bitcoin Core.</div>`;
    } catch (e) {
        alertDiv.innerHTML = `<div class="alert error">${e}</div>`;
    }
}
```

- [ ] **Step 2: Add the txindex enable button**

Replace the `txIdx` assignment in `loadExplorer`:

```javascript
        const txIdx = info.txindex
            ? '<span style="color:var(--accent);">txindex=1 (full archival)</span>'
            : `<span style="color:var(--warning);">txindex not enabled — only wallet transactions searchable.</span> <button class="secondary" onclick="fixBitcoinConf(['txindex=1'], {confirm: 'Enabling txindex makes Bitcoin Core re-index the whole chain on next start, which can take several hours. Continue?'})" style="margin-left:8px;padding:2px 10px;font-size:12px;border-radius:7px;">Enable txindex</button>`;
```

- [ ] **Step 3: Syntax check**

Run: `node --check tauri/src/app.js`
Expected: OK.

- [ ] **Step 4: Commit**

```bash
git add tauri/src/app.js
git commit -m "feat(explorer): one-click fix for rest=1 and txindex=1"
```

---

### Task 12: Full manual verification

- [ ] **Step 1: rest=1 path**

Rebuild. On a node without `rest=1`, click Start → see the rest error + "Fix bitcoin.conf for me". Click it → conf updated + `.bak` created (`ls ~/Documents/bitcoin.conf.bak`), prompt to restart. Restart Core, click Start again.
Expected: electrs runs (`pgrep -lf electrs` shows it), status reads "syncing" with progress, `~/Armory/electrs-db` grows.

- [ ] **Step 2: user/password auth path**

With `rpcuser`/`rpcpassword` set (no `.cookie`), confirm `~/Armory/electrs/cookie` is created (mode 600) and electrs authenticates.

- [ ] **Step 3: txindex path**

Click "Enable txindex" → confirm dialog → conf updated, restart prompt shown.

- [ ] **Step 4: regression**

Run: `python3 -m unittest discover -s tests` and `source "$HOME/.cargo/env" && cd tauri/src-tauri && cargo test`
Expected: new tests pass; pre-existing failures unchanged (6 in the Python suite, documented earlier).

---

## Self-Review Notes

- **Spec coverage:** Component 1 → Tasks 1–4; Component 2 → Task 8; Component 3 → Tasks 7–8; Component 4 → Tasks 9–11; Component 5 → Task 6. Manual verification → Tasks 5, 12.
- **Auth-no-conf-edit** decision honored: cookie synthesized (Task 6), conf edits limited to `rest=1`/`txindex=1` (Tasks 9–11).
- **Single-source-of-truth**: all conf parsing/editing in `online/setup.py`; Rust consumes derived path/port via endpoints.
- **Known follow-up:** `install_electrs` auto-start (Task 8 Step 3) uses a best-effort cookie (datadir `.cookie` only) because the spawned thread can't call the preflight endpoint; the explicit Start button covers the full path. Logged here intentionally rather than silently capped.
