Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v7

- name: Update version in pyproject.toml
- name: Calculate prerelease version
id: set-version
run: |
# Get base version from tag action (e.g., v1.2.0-rc.0)
Expand All @@ -98,15 +98,15 @@ jobs:
# Create unique prerelease version: {base}rc{pr_number}.dev{run_number}
# e.g., 1.2.0rc42.dev7 for PR #42, run #7
PYPI_VERSION="${BASE_VERSION}rc${{ github.event.pull_request.number }}.dev${{ github.run_number }}"
sed -i "s/^version = .*/version = \"$PYPI_VERSION\"/" pyproject.toml
echo "Tag version: $NEW_TAG"
echo "PyPI version: $PYPI_VERSION"
echo "pypi_version=$PYPI_VERSION" >> $GITHUB_OUTPUT
grep "^version" pyproject.toml
env:
NEW_TAG: ${{ steps.version.outputs.new_tag }}

- name: Build distributions
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.set-version.outputs.pypi_version }}
run: |
uv build --sdist
uv build --wheel
Expand Down
24 changes: 8 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,17 @@ jobs:
BRANCH_HISTORY: compare
INITIAL_VERSION: 1.0.3

- name: Update version in pyproject.toml
- name: Set version output
id: set-version
run: |
# Strip 'v' prefix for PyPI-compatible version
PYPI_VERSION="${NEW_TAG#v}"
sed -i "s/^version = .*/version = \"$PYPI_VERSION\"/" pyproject.toml
echo "Tag version: $NEW_TAG"
echo "PyPI version: $PYPI_VERSION"
echo "pypi_version=$PYPI_VERSION" >> $GITHUB_OUTPUT
grep "^version" pyproject.toml
env:
NEW_TAG: ${{ steps.version.outputs.new_tag }}
# Note: Version is injected locally for this job only.
# Not committed to repo - downstream jobs inject version from tag.
# Note: hatch-vcs reads version from git tag at build time

publish:
name: Publish PyPI
Expand All @@ -67,10 +64,13 @@ jobs:
- uses: actions/checkout@v5
with:
ref: master
fetch-depth: 1
fetch-depth: 0 # Full history needed for hatch-vcs

- name: Pull latest changes
run: git pull origin master
- name: Pull latest and fetch tags
run: |
git pull origin master
git fetch --tags
echo "Latest tag: $(git describe --tags --abbrev=0)"

- name: Set up Python
uses: actions/setup-python@v6
Expand All @@ -80,14 +80,6 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v7

- name: Inject version from tag
run: |
PYPI_VERSION="${{ needs.version.outputs.new_version }}"
PYPI_VERSION="${PYPI_VERSION#v}" # Strip 'v' prefix
sed -i "s/^version = .*/version = \"$PYPI_VERSION\"/" pyproject.toml
echo "PyPI version: $PYPI_VERSION"
grep "^version" pyproject.toml

- name: Build distributions
run: |
uv build --sdist
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ jobs:
- name: Install dependencies
run: make ci DEV=1

- name: Lint with ruff
- name: Lint
run: make lint

- name: Run tests with coverage
- name: Type check
run: make type

- name: Test with coverage
run: make cov
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
misc
*.txt

src/interstellar/_version.py

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# AGENTS.md

## Overview

This repo is a Python CLI tool (`interstellar/`) for managing cryptocurrency mnemonics using BIP39 and SLIP39 standards — including generation, splitting, reconstruction, and Shamir secret sharing.

## CI Checks

All changes must pass these checks before merging (runs on ubuntu, macOS, and Windows):

```bash
make lint # ruff check . && ruff format --check .
make type # ty check src tests
make cov # pytest with coverage (threshold enforced)
```

## Running Checks Locally

```bash
make ci DEV=1 # install deps (frozen lockfile, requires uv)
make lint # lint + format check
make type # type check (ty)
make test # unit tests only
make cov # unit tests with coverage
make smoke # smoke tests (tests/smoke.py)
make format # auto-fix lint + format
```

## Project Structure

- `src/interstellar/` — core CLI module (BIP39, SLIP39, CLI app)
- `tests/` — pytest unit tests and smoke tests
- `scripts/` — utility scripts (build, QR codes)
- `pyproject.toml` — project config, dependencies, and tool settings
- `Makefile` — build/test/lint commands

## Key Conventions

- **Package manager**: `uv` (all commands run via `uv run`)
- **Type checker**: `ty` (astral, not mypy)
- **Linter/Formatter**: `ruff`
- **Test runner**: `pytest` with `pytest-xdist` (`-n auto`) and `pytest-cov`
- **Python version**: 3.13
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ NAME := $(shell basename $(CURDIR))
UV_SYNC := uv sync $(if $(DEV),--dev,--no-dev)
UV_SYNC_FROZEN := uv sync --frozen $(if $(DEV),--dev,--no-dev)

.PHONY: install ci lint format smoke test cov build clean qr help all
.PHONY: install ci lint type format smoke test cov build clean qr help all

help:
@echo "Available targets:"
@echo " install - Install dependencies (DEV=1 for dev deps)"
@echo " ci - Install with frozen lock file (DEV=1 for dev deps)"
@echo " lint - Run ruff linter and formatter check"
@echo " type - Run type checker (ty)"
@echo " format - Run ruff formatter"
@echo " smoke - Run smoke tests"
@echo " test - Run tests with pytest"
Expand All @@ -30,6 +31,9 @@ lint:
uv run ruff check .
uv run ruff format --check .

type:
uv run ty check src tests

format:
uv run ruff check . --fix
uv run ruff format .
Expand Down
23 changes: 16 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
name = "interstellar"
version = "0.0.0" # Injected from git tag at build time
dynamic = ["version"]
description = "A command-line tool for managing cryptocurrency mnemonics using BIP39 and SLIP39 standards"
readme = "README.md"
requires-python = ">=3.13"
Expand All @@ -31,6 +31,12 @@ Homepage = "https://github.com/alkalescent/interstellar"
Repository = "https://github.com/alkalescent/interstellar"
Issues = "https://github.com/alkalescent/interstellar/issues"

[tool.hatch.version]
source = "vcs"

[tool.hatch.build.hooks.vcs]
version-file = "src/interstellar/_version.py"

[tool.hatch.build.targets.wheel]
packages = ["src/interstellar"]

Expand All @@ -39,15 +45,16 @@ packages = ["src/interstellar"]
interstellar = "interstellar.cli:app"

[dependency-groups]
dev = ["pytest", "pytest-xdist", "pytest-cov", "nuitka", "ordered-set", "ruff", "packaging", "qrcode[pil]"]
dev = ["pytest", "pytest-xdist", "pytest-cov", "nuitka", "ordered-set", "ruff", "packaging", "qrcode[pil]", "ty"]

[tool.pytest.ini_options]
addopts = "-n auto"
testpaths = ["tests"]

[tool.coverage.run]
source = ["src"]
omit = ["tests/*", ".venv/*", "build/*", "scripts/*", "*/__main__.py"]
omit = ["tests/*", ".venv/*", "build/*", "scripts/*", "*/__main__.py", "*/_version.py"] # Auto-generated by hatch-vcs


[tool.coverage.report]
fail_under = 90
Expand All @@ -56,6 +63,7 @@ show_missing = true
[tool.ruff]
target-version = "py313"
line-length = 88
exclude = ["src/*/_version.py"] # Auto-generated by hatch-vcs

[tool.ruff.lint]
select = [
Expand All @@ -81,9 +89,10 @@ ignore = [
[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["ANN", "D"] # Don't require annotations/docstrings in tests

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.ty]
[tool.ty.environment]
python-version = "3.13"
23 changes: 13 additions & 10 deletions src/interstellar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,31 +243,34 @@ def reconstruct(
) -> None:
"""Reconstruct a BIP39 mnemonic from SLIP39 shares or BIP39 parts."""
cli.enforce_standard(standard)
if not shares and filename:
share_groups: list[list[str]] = shares or [] # type: ignore[assignment]
if not share_groups and filename:
try:
shares = cli.get_mnemos(filename)
share_groups = cli.get_mnemos(filename)
except FileNotFoundError:
raise typer.BadParameter(f"File not found: {filename}") from None
if not shares:
if not share_groups:
raise typer.BadParameter("Shares are required")

required = 0
reconstructed_parts: list[str] = []

if standard.upper() == "SLIP39":
groups = shares
shares = []
for gidx, group in enumerate(groups):
for gidx, group in enumerate(share_groups):
if digits:
group = [_convert_digits_to_words(member, "SLIP39") for member in group]

required = cli.slip39.get_required(group[gidx])
shares.append(cli.slip39.reconstruct(group))
reconstructed_parts.append(cli.slip39.reconstruct(group))
else: # BIP39
shares = [part for group in shares for part in group]
reconstructed_parts = [part for group in share_groups for part in group]
if digits:
# Convert 1-indexed digits back to words
shares = [_convert_digits_to_words(share, "BIP39") for share in shares]
reconstructed = cli.bip39.reconstruct(shares)
reconstructed_parts = [
_convert_digits_to_words(share, "BIP39")
for share in reconstructed_parts
]
reconstructed = cli.bip39.reconstruct(reconstructed_parts)
output = {
"standard": "BIP39",
"mnemonic": reconstructed,
Expand Down
47 changes: 31 additions & 16 deletions src/interstellar/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ def deconstruct(self, mnemo: str, split: int = 2) -> list[str]:
raise ValueError("Invalid BIP39 entropy split.")
# Split the entropy into split parts
size = len(entropy) // split
entropies = [entropy[i * size : (i + 1) * size] for i in range(split)]
entropies = [bytes(entropy[i * size : (i + 1) * size]) for i in range(split)]
mnemos = [self.mnemo.to_mnemonic(ent) for ent in entropies]
# Check if the mnemonics are valid
if not all(self.mnemo.check(mnemo) for mnemo in mnemos):
if not all(self.mnemo.check(m) for m in mnemos):
raise ValueError("Invalid BIP39 mnemonics after deconstruction.")
return mnemos

Expand All @@ -80,9 +80,12 @@ def eth(self, mnemo: str) -> str:
Returns:
The derived Ethereum address.
"""
mnemo = BIP39Mnemonic(mnemo)
wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic(mnemo)
bip39_mnemo = BIP39Mnemonic(mnemo)
wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic(
bip39_mnemo
)
addr = wallet.address()
assert addr is not None, "Failed to derive Ethereum address"
return addr

def generate(self, num_words: int) -> str:
Expand All @@ -94,8 +97,10 @@ def generate(self, num_words: int) -> str:
Returns:
A randomly generated BIP39 mnemonic phrase.
"""
mnemo = BIP39Mnemonic.from_words(num_words, BIP39_MNEMONIC_LANGUAGES.ENGLISH)
return mnemo
generated = BIP39Mnemonic.from_words(
num_words, BIP39_MNEMONIC_LANGUAGES.ENGLISH
)
return str(generated)


class SLIP39:
Expand All @@ -119,10 +124,11 @@ def deconstruct(self, mnemo: str, required: int = 2, total: int = 3) -> list[str
Returns:
List of SLIP39 share mnemonics.
"""
_, shares = slip39.api.create(
result = slip39.api.create(
"LEDGER", 1, {"KEYS": (required, total)}, mnemo, using_bip39=True
).groups["KEYS"]
return shares
)
_, shares = result.groups["KEYS"] # type: ignore[union-attr]
return list(shares)

def reconstruct(self, shares: list[str]) -> str:
"""Reconstruct a BIP39 mnemonic from SLIP39 shares.
Expand All @@ -133,9 +139,13 @@ def reconstruct(self, shares: list[str]) -> str:
Returns:
The reconstructed BIP39 mnemonic.
"""
entropy = slip39.recovery.recover(shares, using_bip39=True, as_entropy=True)
mnemo = self.mnemo.to_mnemonic(entropy)
return mnemo
entropy = slip39.recovery.recover(
shares, # type: ignore[arg-type]
using_bip39=True,
as_entropy=True,
)
reconstructed = self.mnemo.to_mnemonic(entropy)
return reconstructed

def get_required(self, share: str) -> int:
"""Extract required threshold from a SLIP39 share.
Expand All @@ -158,9 +168,12 @@ def eth(self, mnemo: str) -> str:
Returns:
The derived Ethereum address.
"""
mnemo = SLIP39Mnemonic(mnemo)
wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic(mnemo)
slip39_mnemo = SLIP39Mnemonic(mnemo)
wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic(
slip39_mnemo
)
addr = wallet.address()
assert addr is not None, "Failed to derive Ethereum address"
return addr

def generate(self, num_words: int) -> str:
Expand All @@ -172,5 +185,7 @@ def generate(self, num_words: int) -> str:
Returns:
A randomly generated SLIP39 mnemonic phrase.
"""
mnemo = SLIP39Mnemonic.from_words(num_words, SLIP39_MNEMONIC_LANGUAGES.ENGLISH)
return mnemo
generated = SLIP39Mnemonic.from_words(
num_words, SLIP39_MNEMONIC_LANGUAGES.ENGLISH
)
return str(generated)
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
SPLIT_PARTS = 2


def assert_eth_addr(address):
def assert_eth_addr(address: str) -> None:
"""Assert that address is a valid Ethereum address."""
assert address.startswith("0x") and len(address) == 42
Loading
Loading