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
272 changes: 272 additions & 0 deletions .github/scripts/make_brand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
"""Regenerate the btc-toolkit brand assets from code.

assets/icon.svg the Bitcoin logo (public domain), 64 x 64
assets/logo.svg README header for light backgrounds (also PyPI)
assets/logo-dark.svg README header for GitHub's dark theme
assets/social-preview.png 1280 x 640 banner (GitHub Settings > Social preview)

The wordmark is JetBrains Mono ExtraBold and the copy IBM Plex Sans, both under
the SIL Open Font License. They are downloaded from their upstream
repositories at pinned commits and checked against a SHA-256 before use; the
SVGs carry the wordmark as outlines, so they look the same without the fonts.
Nothing is drawn by hand: run it again after changing the copy below.

pip install pillow fonttools
python .github/scripts/make_brand.py
"""

from __future__ import annotations

import hashlib
import re
import tempfile
import urllib.request
from pathlib import Path

from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parents[2]
ASSETS = ROOT / "assets"

ORANGE = "#F7931A"
INK = "#0D1117"
PANEL = "#161B22"
LINE = "#30363D"
SLATE = "#8B949E"
WHITE = "#FFFFFF"
SNOW = "#E6EDF3"

_JB = "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/19371302b95d218af43299bce79ddbddd0bc364d/fonts/ttf/"
_PLEX = ("https://raw.githubusercontent.com/IBM/plex/39276912b05f700ee1dd491950c35d8697214208/"
"packages/plex-sans/fonts/complete/ttf/")
FONTS = {
"mono-extrabold": (_JB + "JetBrainsMono-ExtraBold.ttf",
"ee43f7507a07ed15c4bf6211d5cc78fa0bacca58e7afbc170f4b1e38a5d0cde4"),
"mono": (_JB + "JetBrainsMono-Regular.ttf",
"e6fd0d7e91550b3ed2b735d4312474362c4716edc4fc0577a0f61ed782d5aed1"),
"sans-medium": (_PLEX + "IBMPlexSans-Medium.ttf",
"331c8639d7598b2cde62a911a71db195e30cb655cd6bdf2e324a7e984955f907"),
"sans": (_PLEX + "IBMPlexSans-Regular.ttf",
"975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5"),
}

# The Bitcoin logo's "B", in a 64 x 64 box (public domain artwork).
BITCOIN_B = (
"M46.1 27.4c.6-4.2-2.6-6.5-7-8l1.4-5.7-3.5-.9-1.4 5.5c-.9-.2-1.9-.4-2.8-.7l1.4-5.5-3.5-.9-1.4 5.7"
"c-.8-.2-1.5-.3-2.2-.5l0 0-4.8-1.2-.9 3.7s2.6.6 2.5.6c1.4.4 1.7 1.3 1.6 2l-1.6 6.5c.1 0 .2.1.3.1"
"l-.3-.1-2.3 9.1c-.2.4-.6 1.1-1.6.8 0 0-2.5-.6-2.5-.6l-1.7 4 4.5 1.1c.8.2 1.7.4 2.5.7l-1.4 5.8 3.5.9"
" 1.4-5.7c1 .3 1.9.5 2.8.7l-1.4 5.7 3.5.9 1.4-5.8c5.9 1.1 10.4.7 12.3-4.7 1.5-4.3-.1-6.8-3.2-8.4"
" 2.3-.5 4-2.1 4.4-5.2zm-7.9 11.1c-1.1 4.3-8.3 2-10.7 1.4l1.9-7.6c2.3.6 9.9 1.7 8.8 6.2zm1.1-11.2"
"c-1 3.9-7 1.9-9 1.4l1.7-6.9c2 .5 8.3 1.4 7.3 5.5z"
)

WORDMARK = "btc-toolkit"
HEADLINE = [[("Read and verify Bitcoin", WHITE)], [("from your ", WHITE), ("terminal.", ORANGE)]]
FEATURES = ["OP_RETURN & inscriptions · raw-tx decoding", "offline address checks · Merkle-proof verify"]
INSTALL = "pipx install btc-toolkit"
ALSO = "also uv · brew · pip"


def font_file(name: str) -> Path:
url, sha = FONTS[name]
path = Path(tempfile.gettempdir()) / "btc-toolkit-brand-fonts" / url.rsplit("/", 1)[1]
if not path.exists() or hashlib.sha256(path.read_bytes()).hexdigest() != sha:
path.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310 (fixed https URL)
data = resp.read()
if hashlib.sha256(data).hexdigest() != sha:
raise SystemExit(f"{url}: SHA-256 mismatch, refusing to use it")
path.write_bytes(data)
return path


# --- the Bitcoin logo as polygons (for the PNG) ---------------------------------

_ARGS = {"m": 2, "l": 2, "c": 6, "s": 4, "z": 0}


def logo_polygons(d: str = BITCOIN_B, steps: int = 24) -> list[list[tuple[float, float]]]:
"""Flatten the logo's path (M m l c s z only) into one polygon per subpath."""
tokens = re.findall(r"[MmLlCcSsZz]|-?(?:\d+\.\d*|\.\d+|\d+)", d)
polys: list[list[tuple[float, float]]] = []
x = y = sx = sy = 0.0
last_ctrl: tuple[float, float] | None = None
i, cmd = 0, ""
while i < len(tokens):
if tokens[i].isalpha():
cmd = tokens[i]
i += 1
if cmd in "Zz":
x, y = sx, sy
last_ctrl = None
continue
n = _ARGS[cmd.lower()]
a = [float(t) for t in tokens[i:i + n]]
i += n
rel = cmd.islower()
ox, oy = (x, y) if rel else (0.0, 0.0)
if cmd in "Mm":
x, y = ox + a[0], oy + a[1]
sx, sy = x, y
polys.append([(x, y)])
cmd = "l" if rel else "L" # extra pairs after a moveto are linetos
last_ctrl = None
elif cmd in "Ll":
x, y = ox + a[0], oy + a[1]
polys[-1].append((x, y))
last_ctrl = None
else:
if cmd in "Cc":
c1 = (ox + a[0], oy + a[1])
c2, end = (ox + a[2], oy + a[3]), (ox + a[4], oy + a[5])
else: # Ss: the first control point mirrors the previous curve's second
c1 = (2 * x - last_ctrl[0], 2 * y - last_ctrl[1]) if last_ctrl else (x, y)
c2, end = (ox + a[0], oy + a[1]), (ox + a[2], oy + a[3])
p0 = (x, y)
for k in range(1, steps + 1):
t = k / steps
u = 1 - t
polys[-1].append((
u**3 * p0[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t**3 * end[0],
u**3 * p0[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t**3 * end[1],
))
last_ctrl = c2
x, y = end
return polys


def draw_logo(draw: ImageDraw.ImageDraw, left: float, top: float, size: float) -> None:
k = size / 64
draw.ellipse((left, top, left + size, top + size), fill=ORANGE)
outer, *holes = logo_polygons()
draw.polygon([(left + px * k, top + py * k) for px, py in outer], fill=WHITE)
for hole in holes:
draw.polygon([(left + px * k, top + py * k) for px, py in hole], fill=ORANGE)


# --- the social preview -------------------------------------------------------------

def social_preview(scale: int = 2) -> Image.Image:
"""The 1280 x 640 banner, drawn at `scale`x and downsampled for smooth edges."""
s = scale
W, H = 1280, 640
img = Image.new("RGB", (W * s, H * s), INK)
d = ImageDraw.Draw(img)

def font(name: str, px: float) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(font_file(name)), round(px * s))

def baseline(top: float, line_h: float, f: ImageFont.FreeTypeFont) -> float:
"""Where CSS puts the baseline of a line box `line_h` tall starting at `top`."""
asc, desc = f.getmetrics()
return top * s + (line_h * s - (asc + desc)) / 2 + asc

def text(x: float, y_base: float, runs: list[tuple[str, str]], f: ImageFont.FreeTypeFont,
tracking: float = 0.0) -> float:
cx = x * s
for chunk, color in runs:
if tracking:
for ch in chunk:
d.text((cx, y_base), ch, font=f, fill=color, anchor="ls")
cx += f.getlength(ch) + tracking * s
else:
d.text((cx, y_base), chunk, font=f, fill=color, anchor="ls")
cx += f.getlength(chunk)
return cx / s - x

wordmark, headline, body = font("mono-extrabold", 76), font("sans-medium", 40), font("sans", 21)
mono, small = font("mono", 22), font("mono", 17)
pill_h = (sum(mono.getmetrics()) / s) + 20
blocks = 76 + 22 + 2 * 48 + 22 + 2 * 31.5 + 30 + pill_h
top = (H - blocks) / 2

coin = 248
draw_logo(d, 88 * s, (H - coin) / 2 * s, coin * s)

x = 88 + coin + 72
y = top
text(x, baseline(y, 76, wordmark), [(WORDMARK, WHITE)], wordmark, tracking=-0.03 * 76)
y += 76 + 22
for line in HEADLINE:
text(x, baseline(y, 48, headline), line, headline)
y += 48
y += 22
for feature in FEATURES:
text(x, baseline(y, 31.5, body), [(feature, SLATE)], body)
y += 31.5
y += 30
cmd_w = mono.getlength("$ " + INSTALL) / s
d.rounded_rectangle((x * s, y * s, (x + cmd_w + 32) * s, (y + pill_h) * s), radius=10 * s,
fill=PANEL, outline=LINE, width=s)
text(x + 16, baseline(y + 10, pill_h - 20, mono), [("$ ", ORANGE), (INSTALL, WHITE)], mono)
also_x = x + cmd_w + 32 + 18
also_h = sum(small.getmetrics()) / s
text(also_x, baseline(y + (pill_h - also_h) / 2, also_h, small), [(ALSO, SLATE)], small)
right = also_x + small.getlength(ALSO) / s
if right > W - 56:
raise SystemExit(f"social preview: text ends at x={right:.0f}, past the 56 px margin")
return img.resize((W, H), Image.Resampling.LANCZOS)


# --- SVGs ---------------------------------------------------------------------------

def outline(text: str, font: TTFont, px: float, x: float, base: float, tracking: float) -> tuple[str, float]:
"""`text` as one SVG path in `font` at `px`, left edge `x`, baseline `base`; returns (d, width)."""
k = px / font["head"].unitsPerEm
cmap, glyphs, hmtx = font.getBestCmap(), font.getGlyphSet(), font["hmtx"]
parts, cx = [], x
for i, ch in enumerate(text):
name = cmap[ord(ch)]
pen = SVGPathPen(glyphs)
glyphs[name].draw(TransformPen(pen, (k, 0, 0, -k, cx, base)))
parts.append(pen.getCommands())
cx += hmtx[name][0] * k + (tracking if i < len(text) - 1 else 0)
return " ".join(p for p in parts if p), cx - x


def _num(v: float) -> str:
return f"{v:.2f}".rstrip("0").rstrip(".")


def logo_svg(wordmark_fill: str) -> str:
font = TTFont(str(font_file("mono-extrabold")))
pad, coin, gap, px = 2, 88, 22, 58
hhea = font["hhea"]
em = font["head"].unitsPerEm
content = (hhea.ascent - hhea.descent) / em * px
base = pad + coin / 2 - content / 2 + hhea.ascent / em * px
path, width = outline(WORDMARK, font, px, pad + coin + gap, base, -0.03 * px)
path = re.sub(r"-?\d+\.\d+", lambda m: _num(float(m.group())), path)
w, h = pad + coin + gap + width + pad, coin + 2 * pad
k = coin / 64
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{_num(w)}" height="{_num(h)}" '
f'viewBox="0 0 {_num(w)} {_num(h)}" role="img" aria-label="btc-toolkit">'
f'<circle cx="{_num(pad + coin / 2)}" cy="{_num(pad + coin / 2)}" r="{_num(coin / 2)}" fill="{ORANGE}"/>'
f'<path transform="translate({pad} {pad}) scale({_num(k)})" d="{BITCOIN_B}" fill="{WHITE}"/>'
f'<path d="{path}" fill="{wordmark_fill}"/></svg>\n'
)


ICON_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" '
f'aria-label="btc-toolkit"><circle cx="32" cy="32" r="32" fill="{ORANGE}"/>'
f'<path d="{BITCOIN_B}" fill="{WHITE}"/></svg>\n'
)


def main() -> None:
ASSETS.mkdir(exist_ok=True)
(ASSETS / "icon.svg").write_text(ICON_SVG, encoding="utf-8")
(ASSETS / "logo.svg").write_text(logo_svg(INK), encoding="utf-8")
(ASSETS / "logo-dark.svg").write_text(logo_svg(SNOW), encoding="utf-8")
social_preview().save(ASSETS / "social-preview.png", optimize=True)
for name in ("icon.svg", "logo.svg", "logo-dark.svg", "social-preview.png"):
print(f"wrote assets/{name}")


if __name__ == "__main__":
main()
11 changes: 9 additions & 2 deletions .github/scripts/make_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 0),
]

ANSI_256 = {208: (255, 135, 0)} # the banner's Bitcoin orange

SGR = re.compile(r"\x1b\[([0-9;]*)m")
OTHER_ESCAPES = re.compile(r"\x1b\[[0-9;?]*[A-Za-ln-z]")

Expand Down Expand Up @@ -95,8 +97,13 @@ def parse(text: str) -> list[Line]:
if m is None:
break
pos = m.end()
for code in [int(c) if c else 0 for c in m.group(1).split(";")]:
if code == 0:
codes = [int(c) if c else 0 for c in m.group(1).split(";")]
while codes:
code = codes.pop(0)
if code == 38 and codes[:1] == [5] and len(codes) > 1: # 256-colour foreground
fg = ANSI_256.get(codes[1], FG)
del codes[:2]
elif code == 0:
fg, bold, dim = None, False, False
elif code == 1:
bold = True
Expand Down
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ versioning follows [SemVer](https://semver.org/). The `--json` output follows th

## [Unreleased]

## [1.8.1] — 2026-10-15

A new look, and a PyPI page that describes 1.8.0. The commands and the `--json` output are the same as in 1.8.0.

### Changed
- New visual identity, built on the Bitcoin logo and its orange (`#F7931A`): `assets/icon.svg`, a README header
for GitHub's light and dark themes (`assets/logo.svg`, `assets/logo-dark.svg`) and the social preview. They are
generated by `.github/scripts/make_brand.py` from pinned, hash-checked fonts (JetBrains Mono, IBM Plex Sans;
SIL Open Font License), so the images can be rebuilt and reviewed from code.
- The banner at the top of the text output is Bitcoin orange (256-colour palette) instead of cyan. `NO_COLOR` and
piped output still print it without colour.
- The PyPI summary and the README's opening lines describe what the tool does since 1.8.0: read and verify Bitcoin
from the terminal (OP_RETURN, inscriptions, raw transactions, offline address checks, Merkle-proof verify).
- README: btc-toolkit's listing among the developer tools in Jameson Lopp's Bitcoin resources.

## [1.8.0] — 2026-10-15

Four new commands (`verify`, `decode`, `validate`, `inscription`), `tx --inputs/--outputs`, and fixes from two
Expand Down Expand Up @@ -273,7 +288,8 @@ First stable release: `opreturn`, `balance`, `fees`, `block`, `utxo` — the ori
- 0.4.0 — block explorer · 0.3.0 — fee estimator · 0.2.0 — balance checker and unified CLI.
- Phase 1 (untagged) — OP_RETURN reader.

[Unreleased]: https://github.com/devdavidejesus/btc-toolkit/compare/v1.8.0...HEAD
[Unreleased]: https://github.com/devdavidejesus/btc-toolkit/compare/v1.8.1...HEAD
[1.8.1]: https://github.com/devdavidejesus/btc-toolkit/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/devdavidejesus/btc-toolkit/compare/v1.7.2...v1.8.0
[1.7.2]: https://github.com/devdavidejesus/btc-toolkit/compare/v1.7.1...v1.7.2
[1.7.1]: https://github.com/devdavidejesus/btc-toolkit/compare/v1.7.0...v1.7.1
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<div align="center">

<img src="https://raw.githubusercontent.com/devdavidejesus/btc-toolkit/main/assets/logo.svg" alt="btc-toolkit" width="640"/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/devdavidejesus/btc-toolkit/main/assets/logo-dark.svg"/>
<img src="https://raw.githubusercontent.com/devdavidejesus/btc-toolkit/main/assets/logo.svg" alt="btc-toolkit" width="480"/>
</picture>

**Bitcoin CLI toolkit — zero dependencies, no Bitcoin Core required.**
**Read and verify Bitcoin from your terminal.** Zero dependencies, no Bitcoin Core required.

Query the Bitcoin network via the [Mempool.space](https://mempool.space) public API, or your own Mempool instance.
Reads the chain through the [Mempool.space](https://mempool.space) public API, or your own Mempool instance, and checks what it can by computation: address checksums offline, and a transaction against its block's Merkle proof and proof of work.

[![Tests](https://github.com/devdavidejesus/btc-toolkit/actions/workflows/tests.yml/badge.svg)](https://github.com/devdavidejesus/btc-toolkit/actions/workflows/tests.yml)
[![PyPI](https://img.shields.io/pypi/v/btc-toolkit?label=PyPI&color=F7931A&logo=pypi&logoColor=white)](https://pypi.org/project/btc-toolkit/)
Expand Down Expand Up @@ -221,7 +224,7 @@ btc-toolkit opreturn <txid> --raw

Known protocol markers are named by their prefix: a Runes Runestone (`OP_RETURN OP_13`), Omni Layer (a payload starting with `omni` — a plain text message can start that way too), and the BIP 141 witness commitment in a coinbase. They are tagged, not decoded.

**Seen in the wild:** the $320M Liquid Network drain (Sept 2026) was negotiated on-chain via OP_RETURN — every message of it decoded with this command, transaction IDs included: [*I Read a $320M Ransom Negotiation From My Terminal*](https://dev.to/devdavidejesus/i-read-a-320m-ransom-negotiation-from-my-terminal-1159). And the wallet holding 562 BTC from the Coldcard hack (July 2026) became a message board: 49 OP_RETURN messages, read with `opreturn --file`: [*49 Messages to a Thief*](https://dev.to/devdavidejesus/49-messages-to-a-thief-what-people-wrote-to-the-coldcard-hackers-bitcoin-wallet-l1n).
**Seen in the wild:** the $320M Liquid Network drain (Sept 2026) was negotiated on-chain via OP_RETURN — every message of it decoded with this command, transaction IDs included: [*I Read a $320M Ransom Negotiation From My Terminal*](https://dev.to/devdavidejesus/i-read-a-320m-ransom-negotiation-from-my-terminal-1159). And the wallet holding 562 BTC from the Coldcard hack (July 2026) became a message board: 49 OP_RETURN messages, read with `opreturn --file`: [*49 Messages to a Thief*](https://dev.to/devdavidejesus/49-messages-to-a-thief-what-people-wrote-to-the-coldcard-hackers-bitcoin-wallet-l1n). btc-toolkit is also listed among the [developer tools](https://www.lopp.net/bitcoin-information/developer-tools.html) in Jameson Lopp's Bitcoin resources.

### Transactions to Try

Expand Down Expand Up @@ -309,10 +312,10 @@ btc-toolkit/
├── tests/ # One file per module, plus CLI, completion and robustness tests — all API calls mocked
├── fuzz/ # Atheris fuzzer for the parsers of untrusted data
├── completions/ # Static bash + zsh completions
├── assets/ # Logo, social preview and the README demos
├── assets/ # Logo, icon, social preview and the README demos
├── .github/
│ ├── workflows/ # CI, release, CodeQL, fuzz, Scorecard, weekly live smoke test
│ ├── scripts/ # Release notes, live smoke test, demo recorder
│ ├── scripts/ # Release notes, live smoke test, demo recorder, brand assets
│ └── requirements/ # Hash-pinned CI tools
├── docs/
│ ├── json-schema.md # --json output per command + stability policy
Expand Down
Loading
Loading