Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ avoid comments or tests that restate the implementation. Add or update concise
docstrings when changing public behavior. Write codex32 in lowercase except
when referring to the Codex32 Book.

Keep the installed package below 5,200 logical review lines, as enforced by the
Keep the installed package below 5,250 logical review lines, as enforced by the
existing test. New dependencies, public API signature or return-shape changes,
and lint suppressions require user authorization; an explicit request can
already provide that authorization.
Expand Down
2 changes: 1 addition & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ unsupported but remains in the review scope.

### Size budget

V1 keeps the installed package below 5,200 logical review lines, excluding
V1 keeps the installed package below 5,250 logical review lines, excluding
blank and comment-only lines while counting subpackages recursively. Changing
the budget requires explicit review and authorization together with the matching
documentation and enforcement update.
Expand Down
7 changes: 5 additions & 2 deletions src/codex32/_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ def connect(
) -> BitcoinCore:
executable = shutil.which("bitcoin-cli")
if executable is None:
raise BitcoinCoreError("Install a reviewed bitcoin-cli before creating a backup.")
raise BitcoinCoreError(
"bitcoin-cli was not found. Install a reviewed Bitcoin Core 32 or newer and run it with RPC "
"enabled. An unsynced regtest or signet node is enough for practice."
)
choices: list[BitcoinCore] = []
for chain, _label in _CHAINS:
client = cls(executable, chain, 0)
Expand All @@ -119,7 +122,7 @@ def connect(
choices.append(cls(executable, chain, version))
if not choices:
raise BitcoinCoreError(
"No local Bitcoin Core RPC server found.\nStart Bitcoin Core "
"No local Bitcoin Core 32 or newer RPC server found.\nStart Bitcoin Core "
"with local RPC enabled.\nFor signet practice: bitcoin-qt -signet -server"
)
if len(choices) > 1:
Expand Down
24 changes: 13 additions & 11 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,22 +442,25 @@ def _initialize_wallet(


def _connected_core(fallback: str | None = None) -> BitcoinCore:
# A pipe can't choose a network, and redirected stderr keeps only the gate's error.
try:
core = BitcoinCore.connect(
lambda prompt: _text(prompt, optional=True),
lambda message: _print(message, err=True),
(lambda prompt: _text(prompt, optional=True)) if sys.stdin.isatty() else None,
(lambda message: _print(message, err=True)) if sys.stderr.isatty() else None,
)
_print("", err=True)
if sys.stderr.isatty():
_print("", err=True)
return core
except KeyboardInterrupt as error:
raise _CoreSelectionInterrupted from error
except BitcoinCoreError as error:
suggestion = (
f" Run 'codex32 {fallback}' instead for a Core-independent operation."
use = (
"uses Bitcoin Core to show the master fingerprint and rank corrections. "
f"'codex32 {fallback}' works without Core but doesn't show the fingerprint."
if fallback is not None
else ""
else "gives Bitcoin Core the master key."
)
raise _CommandError(str(error) + suggestion) from error
raise _CommandError(f"{error}\nThis command {use}") from error


def _create(
Expand Down Expand Up @@ -611,6 +614,8 @@ def _correct(
raise _UsageError("--bytes does not match the valid master-seed backup length.")
_print("The codex32 string is already valid.")
return 0
if context.master_seed:
core = core or _connected_core("correct")
Comment thread
BenWestgate marked this conversation as resolved.
Comment thread
BenWestgate marked this conversation as resolved.
search_value, erased, immutable = normalized, normalized, normalized[: separator + 1]
interpreted = _case_interpretation(normalized, immutable, context.profiles, None)
if interpreted is not None:
Expand All @@ -632,15 +637,12 @@ def _correct(
raise _CommandError("The correction search did not complete within ten seconds.")
if not candidates:
raise _CommandError("No valid correction found. Check the original backup.")
if context.master_seed and len(candidates) > 1:
core = core or _connected_core("correct")
if core is not None and len(candidates) > 1:
candidates = _best(candidates, fingerprint_match=_fingerprint_matcher(core.fingerprint))
if len(candidates) != 1:
raise _CommandError("Several corrections are possible. Check the original backup.")
fixed = candidates[0]
_require_correction_confirmation(fixed.low_checksum_discrimination)
if context.master_seed:
core = core or _connected_core("correct")
warning = (
"Warning: This is only a correction suggestion. Compare it with the original backup before using it."
)
Expand Down
11 changes: 9 additions & 2 deletions tests/test_bitcoin_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def test_preflight_rejection_is_helpful_without_echoing_core_output(

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

with pytest.raises(BitcoinCoreError, match="No local Bitcoin Core RPC server"):
with pytest.raises(BitcoinCoreError, match="No local Bitcoin Core 32 or newer RPC server"):
BitcoinCore.connect()


def test_missing_bitcoin_cli_names_the_core_requirement(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("codex32._bitcoin_core.shutil.which", lambda _name: None)

with pytest.raises(BitcoinCoreError, match="Bitcoin Core 32 or newer and run it with RPC enabled"):
BitcoinCore.connect()


Expand Down
65 changes: 63 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import importlib
import io
import json
import re
import subprocess
import sys
Expand Down Expand Up @@ -32,7 +33,7 @@
parse_codex32,
recover_secret,
)
from codex32._bitcoin_core import BitcoinCore
from codex32._bitcoin_core import BitcoinCore, BitcoinCoreError
from codex32.bech32 import _chars_to_u5, bech32_encode
from codex32.checksums import _CODEX32, _CODEX32_LONG
from codex32.cli import main, ms_main
Expand Down Expand Up @@ -123,6 +124,9 @@ def initialize(
return "test-wallet"


_REAL_CONNECT = BitcoinCore.connect


@pytest.fixture(autouse=True)
def _offline_core(monkeypatch):
monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: _FakeBitcoinCore())
Expand Down Expand Up @@ -287,6 +291,63 @@ def test_check_accepts_shared_core_lightning_artifacts(value: str) -> None:
assert result.stderr == ""


def _missing_core(*_args: object, **_kwargs: object) -> BitcoinCore:
raise BitcoinCoreError("bitcoin-cli was not found.")


@pytest.mark.parametrize("command", ("secret", "share", "correct"))
def test_missing_core_names_the_codex32_fallback(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], command: str
) -> None:
def search(*_args: object, **_kwargs: object) -> None:
raise AssertionError("searched before connecting to Bitcoin Core")

monkeypatch.setattr("codex32.cli.BitcoinCore.connect", _missing_core)
monkeypatch.setattr("codex32.cli._scheduled_candidates", search)
monkeypatch.setattr(sys, "stdin", io.StringIO(VECTOR_1["secret_s"].replace("x", "q", 1) + "\n"))

assert ms_main([command, "d"] if command == "share" else [command]) == (3 if command == "correct" else 1)
assert capsys.readouterr().err == (
f"ms32 {command}: bitcoin-cli was not found.\nThis command uses Bitcoin Core to show the master "
f"fingerprint and rank corrections. 'codex32 {command}' works without Core but doesn't show the "
"fingerprint.\n"
)


def test_missing_core_for_wallet_setup_offers_no_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
cli = importlib.import_module("codex32.cli")
monkeypatch.setattr("codex32.cli.BitcoinCore.connect", _missing_core)

with pytest.raises(cli._CommandError) as failure:
cli._connected_core()

assert str(failure.value) == "bitcoin-cli was not found.\nThis command gives Bitcoin Core the master key."


def test_piped_input_never_answers_the_core_network_choice(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
def run(command: list[str], **_options: object) -> subprocess.CompletedProcess[str]:
chain = command[1].removeprefix("-chain=")
if chain not in ("main", "signet"):
return subprocess.CompletedProcess(command, 1, "", "")
response = {"version": 320000} if command[-1] == "getnetworkinfo" else {"chain": chain}
return subprocess.CompletedProcess(command, 0, json.dumps(response), "")

damaged = VECTOR_1["secret_s"].replace("x", "q", 1)
reads = iter((damaged,)) # A second read would be the pipe's EOF answering the network prompt.
monkeypatch.setattr("codex32._bitcoin_core.shutil.which", lambda _name: "/reviewed/bitcoin-cli")
monkeypatch.setattr(subprocess, "run", run)
monkeypatch.setattr(BitcoinCore, "connect", _REAL_CONNECT)
monkeypatch.setattr(sys, "stdin", io.StringIO(damaged))
monkeypatch.setattr("codex32._cli_input._stdin", lambda: next(reads))

assert ms_main(["correct"]) == 3
assert capsys.readouterr().err.startswith(
"ms32 correct: More than one local Bitcoin Core network is running.\nThis command uses Bitcoin Core"
)


def test_check_does_not_derive_wallet_keys(monkeypatch: pytest.MonkeyPatch) -> None:
cli_module = importlib.import_module("codex32.cli")

Expand Down Expand Up @@ -2193,7 +2254,7 @@ def test_production_size_budgets_are_enforced() -> None:
for path in package.rglob("*.py")
}

assert sum(counts.values()) < 5200, counts
assert sum(counts.values()) < 5250, counts


@pytest.mark.parametrize(
Expand Down
7 changes: 7 additions & 0 deletions tests/test_correction_disclosure.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,18 @@ def test_public_api_full_checksum_erasure_completion_carries_risk(source, degree
@pytest.mark.parametrize("plain", (False, True))
def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain):
stdout, stderr = io.StringIO(), io.StringIO()

def connect(ask=None, tell=None):
if tell is not None:
tell("Using Bitcoin Core on signet.")
return _FakeBitcoinCore()

with (
patch.object(sys, "stdin", io.StringIO(VECTOR_1["secret_s"][:-1] + "?")),
contextlib.redirect_stdout(stdout),
contextlib.redirect_stderr(stderr),
patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None)),
patch.object(cli.BitcoinCore, "connect", connect),
):
status = entrypoint(["correct", *(["--plain"] if plain else [])])
prog = "codex32" if entrypoint is cli.main else "ms32"
Expand Down
Loading