Skip to content

Commit 799bffe

Browse files
claudeBenWestgate
authored andcommitted
cli: Name the real Bitcoin Core requirement when it is missing
Without bitcoin-cli every ms32 command said "Install a reviewed bitcoin-cli before creating a backup", even commands that create nothing, and the codex32 hint didn't say what it leaves out. Say that Bitcoin Core 32 or newer must run with RPC enabled and that an unsynced regtest or signet node is enough for practice. Then say what the command uses Core for: the master fingerprint and correction ranking for secret, share and correct (with the codex32 fallback and what it omits), or giving Core the master key for create and wallet. ms32 correct now connects before its search instead of after it, so a missing Core no longer costs up to ten seconds of discarded work. Closes #84 Claude-Session: https://claude.ai/code/session_015CuLXqAvAovfoVcUmmogwa
1 parent 115f2c2 commit 799bffe

5 files changed

Lines changed: 65 additions & 15 deletions

File tree

‎src/codex32/_bitcoin_core.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ def connect(
102102
) -> BitcoinCore:
103103
executable = shutil.which("bitcoin-cli")
104104
if executable is None:
105-
raise BitcoinCoreError("Install a reviewed bitcoin-cli before creating a backup.")
105+
raise BitcoinCoreError(
106+
"bitcoin-cli was not found. Install a reviewed Bitcoin Core 32 or newer and run it with RPC "
107+
"enabled. An unsynced regtest or signet node is enough for practice."
108+
)
106109
choices: list[BitcoinCore] = []
107110
for chain, _label in _CHAINS:
108111
client = cls(executable, chain, 0)
@@ -119,7 +122,7 @@ def connect(
119122
choices.append(cls(executable, chain, version))
120123
if not choices:
121124
raise BitcoinCoreError(
122-
"No local Bitcoin Core RPC server found.\nStart Bitcoin Core "
125+
"No local Bitcoin Core 32 or newer RPC server found.\nStart Bitcoin Core "
123126
"with local RPC enabled.\nFor signet practice: bitcoin-qt -signet -server"
124127
)
125128
if len(choices) > 1:

‎src/codex32/cli.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -452,12 +452,13 @@ def _connected_core(fallback: str | None = None) -> BitcoinCore:
452452
except KeyboardInterrupt as error:
453453
raise _CoreSelectionInterrupted from error
454454
except BitcoinCoreError as error:
455-
suggestion = (
456-
f" Run 'codex32 {fallback}' instead for a Core-independent operation."
455+
use = (
456+
"uses Bitcoin Core to show the master fingerprint and rank corrections. "
457+
f"'codex32 {fallback}' works without Core but doesn't show the fingerprint."
457458
if fallback is not None
458-
else ""
459+
else "gives Bitcoin Core the master key."
459460
)
460-
raise _CommandError(str(error) + suggestion) from error
461+
raise _CommandError(f"{error}\nThis command {use}") from error
461462

462463

463464
def _create(
@@ -611,6 +612,8 @@ def _correct(
611612
raise _UsageError("--bytes does not match the valid master-seed backup length.")
612613
_print("The codex32 string is already valid.")
613614
return 0
615+
if context.master_seed:
616+
core = core or _connected_core("correct")
614617
search_value, erased, immutable = normalized, normalized, normalized[: separator + 1]
615618
interpreted = _case_interpretation(normalized, immutable, context.profiles, None)
616619
if interpreted is not None:
@@ -632,15 +635,12 @@ def _correct(
632635
raise _CommandError("The correction search did not complete within ten seconds.")
633636
if not candidates:
634637
raise _CommandError("No valid correction found. Check the original backup.")
635-
if context.master_seed and len(candidates) > 1:
636-
core = core or _connected_core("correct")
638+
if core is not None and len(candidates) > 1:
637639
candidates = _best(candidates, fingerprint_match=_fingerprint_matcher(core.fingerprint))
638640
if len(candidates) != 1:
639641
raise _CommandError("Several corrections are possible. Check the original backup.")
640642
fixed = candidates[0]
641643
_require_correction_confirmation(fixed.low_checksum_discrimination)
642-
if context.master_seed:
643-
core = core or _connected_core("correct")
644644
warning = (
645645
"Warning: This is only a correction suggestion. Compare it with the original backup before using it."
646646
)

‎tests/test_bitcoin_core.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def test_preflight_rejection_is_helpful_without_echoing_core_output(
176176

177177
message = str(failure.value)
178178
assert message == (
179-
"No local Bitcoin Core RPC server found.\n"
179+
"No local Bitcoin Core 32 or newer RPC server found.\n"
180180
"Start Bitcoin Core with local RPC enabled.\n"
181181
"For signet practice: bitcoin-qt -signet -server"
182182
)
@@ -199,7 +199,14 @@ def run(command: list[str], **_options: object) -> subprocess.CompletedProcess[s
199199
monkeypatch.setattr("codex32._bitcoin_core.shutil.which", lambda _name: "/reviewed/bitcoin-cli")
200200
monkeypatch.setattr(subprocess, "run", run)
201201

202-
with pytest.raises(BitcoinCoreError, match="No local Bitcoin Core RPC server"):
202+
with pytest.raises(BitcoinCoreError, match="No local Bitcoin Core 32 or newer RPC server"):
203+
BitcoinCore.connect()
204+
205+
206+
def test_missing_bitcoin_cli_names_the_core_requirement(monkeypatch: pytest.MonkeyPatch) -> None:
207+
monkeypatch.setattr("codex32._bitcoin_core.shutil.which", lambda _name: None)
208+
209+
with pytest.raises(BitcoinCoreError, match="Bitcoin Core 32 or newer and run it with RPC enabled"):
203210
BitcoinCore.connect()
204211

205212

‎tests/test_cli.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
parse_codex32,
3333
recover_secret,
3434
)
35-
from codex32._bitcoin_core import BitcoinCore
35+
from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError
3636
from codex32.bech32 import _chars_to_u5, bech32_encode
3737
from codex32.checksums import _CODEX32, _CODEX32_LONG
3838
from codex32.cli import main, ms_main
@@ -287,6 +287,39 @@ def test_check_accepts_shared_core_lightning_artifacts(value: str) -> None:
287287
assert result.stderr == ""
288288

289289

290+
def _missing_core(*_args: object, **_kwargs: object) -> BitcoinCore:
291+
raise BitcoinCoreError("bitcoin-cli was not found.")
292+
293+
294+
@pytest.mark.parametrize("command", ("secret", "share", "correct"))
295+
def test_missing_core_names_the_codex32_fallback(
296+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], command: str
297+
) -> None:
298+
def search(*_args: object, **_kwargs: object) -> None:
299+
raise AssertionError("searched before connecting to Bitcoin Core")
300+
301+
monkeypatch.setattr("codex32.cli.BitcoinCore.connect", _missing_core)
302+
monkeypatch.setattr("codex32.cli._scheduled_candidates", search)
303+
monkeypatch.setattr(sys, "stdin", io.StringIO(VECTOR_1["secret_s"].replace("x", "q", 1) + "\n"))
304+
305+
assert ms_main([command, "d"] if command == "share" else [command]) == (3 if command == "correct" else 1)
306+
assert capsys.readouterr().err == (
307+
f"ms32 {command}: bitcoin-cli was not found.\nThis command uses Bitcoin Core to show the master "
308+
f"fingerprint and rank corrections. 'codex32 {command}' works without Core but doesn't show the "
309+
"fingerprint.\n"
310+
)
311+
312+
313+
def test_missing_core_for_wallet_setup_offers_no_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
314+
cli = importlib.import_module("codex32.cli")
315+
monkeypatch.setattr("codex32.cli.BitcoinCore.connect", _missing_core)
316+
317+
with pytest.raises(cli._CommandError) as failure:
318+
cli._connected_core()
319+
320+
assert str(failure.value) == "bitcoin-cli was not found.\nThis command gives Bitcoin Core the master key."
321+
322+
290323
def test_check_does_not_derive_wallet_keys(monkeypatch: pytest.MonkeyPatch) -> None:
291324
cli_module = importlib.import_module("codex32.cli")
292325

‎tests/test_correction_disclosure.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,24 @@ def test_public_api_full_checksum_erasure_completion_carries_risk(source, degree
9797
@pytest.mark.parametrize("entrypoint", (cli.main, cli.ms_main))
9898
@pytest.mark.parametrize("plain", (False, True))
9999
def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain):
100-
stdout, stderr = io.StringIO(), io.StringIO()
100+
stdout, stderr, connected = io.StringIO(), io.StringIO(), []
101+
102+
def connect(*args):
103+
connected.append(_FakeBitcoinCore())
104+
return connected[-1]
105+
101106
with (
102107
patch.object(sys, "stdin", io.StringIO(VECTOR_1["secret_s"][:-1] + "?")),
103108
contextlib.redirect_stdout(stdout),
104109
contextlib.redirect_stderr(stderr),
105110
patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None)),
111+
patch.object(cli.BitcoinCore, "connect", connect),
106112
):
107113
status = entrypoint(["correct", *(["--plain"] if plain else [])])
108114
prog = "codex32" if entrypoint is cli.main else "ms32"
109115
assert status == 3 and stdout.getvalue() == ""
110-
assert stderr.getvalue() == f"{prog}: interactive confirmation required\n"
116+
# ms32 connects to Core before searching; that prints only a blank line.
117+
assert stderr.getvalue() == "\n" * len(connected) + f"{prog}: interactive confirmation required\n"
111118

112119

113120
@pytest.mark.parametrize("answer", ("y", "Y", "yes", "Yes", "n", "", "other", None))

0 commit comments

Comments
 (0)