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
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Query the Bitcoin network directly via the [Mempool.space](https://mempool.space
| `btc-toolkit balance <address>` | Confirmed + unconfirmed balance of any address | ✅ Phase 2 |
| `btc-toolkit fees` | Recommended fee rates + mempool backlog | ✅ Phase 3 |
| `btc-toolkit block <height\|hash>` | Block metadata by height, hash, or latest | ✅ Phase 4 |
| `btc-toolkit utxo <address>` | UTXO set inspector | Phase 5 |
| `btc-toolkit utxo <address>` | Unspent outputs of any address | ✅ Phase 5 |

## Installation

Expand Down Expand Up @@ -90,6 +90,27 @@ btc-toolkit block latest --json
btc-toolkit block latest --network testnet
```

### utxo — unspent outputs of any address

```bash
btc-toolkit utxo bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq
```

Lists every UTXO sorted by value (largest first), with txid:vout, value in BTC and sats, confirmation status, and block height. Shows aggregate count and total value.

> **Known limitation:** addresses with tens of thousands of UTXOs (e.g. Satoshi's genesis address, ~76k donation outputs) exceed the upstream electrs response limit and return HTTP 400. Use `balance` for aggregate stats on such addresses — discovered and verified in production.

```bash
# Only confirmed UTXOs
btc-toolkit utxo <address> --confirmed-only

# Show more than 15 entries
btc-toolkit utxo <address> --limit 50

# JSON output (always includes all UTXOs)
btc-toolkit utxo <address> --json
```

### opreturn — decode embedded messages

```bash
Expand Down Expand Up @@ -133,12 +154,14 @@ btc-toolkit/
│ ├── opreturn.py # Phase 1 — OP_RETURN decoder
│ ├── balance.py # Phase 2 — Address balance checker
│ ├── fees.py # Phase 3 — Fee estimator
│ └── block.py # Phase 4 — Block info explorer
│ ├── block.py # Phase 4 — Block info explorer
│ └── utxo.py # Phase 5 — UTXO set inspector
├── tests/
│ ├── test_opreturn.py # 18 tests (mocked API + parser validation)
│ ├── test_balance.py # 18 tests (sats math + API response parsing)
│ ├── test_fees.py # 6 tests (rates + backlog parsing)
│ └── test_block.py # 12 tests (ref detection + genesis data)
│ ├── test_block.py # 12 tests (ref detection + genesis data)
│ └── test_utxo.py # 8 tests (aggregates + filters)
├── pyproject.toml
├── LICENSE # MIT
└── README.md
Expand All @@ -154,7 +177,7 @@ Zero external dependencies — Python standard library only (`urllib`, `json`, `
python -m pytest tests/ -v
```

54 tests, all API calls mocked — the suite runs offline.
62 tests, all API calls mocked — the suite runs offline.

![Tests passing](assets/tests.png)

Expand All @@ -176,9 +199,9 @@ This is the same model used by Esplora/Electrs. Don't trust this README — veri
- [x] **Phase 2** — Address Balance Checker
- [x] **Phase 3** — Fee Estimator (mempool-based)
- [x] **Phase 4** — Block Info Explorer
- [ ] **Phase 5** — UTXO Set Inspector
- [x] **Phase 5** — UTXO Set Inspector

All phases follow the same philosophy: **zero dependencies, no Bitcoin Core, verify everything on-chain.**
All five phases complete — one philosophy throughout: **zero dependencies, no Bitcoin Core, verify everything on-chain.**

## Don't Trust, Verify

Expand All @@ -187,6 +210,7 @@ Every txid, address, hex value, and technical claim in this README can be indepe
- Address data: `https://mempool.space/api/address/<address>`
- Fee data: `https://mempool.space/api/v1/fees/recommended`
- Block data: `https://mempool.space/api/block/<hash>`
- UTXO data: `https://mempool.space/api/address/<address>/utxo`
- OP_RETURN spec: [learnmeabitcoin.com/technical/script/return](https://learnmeabitcoin.com/technical/script/return/)
- Esplora API model: [github.com/Blockstream/esplora/blob/master/API.md](https://github.com/Blockstream/esplora/blob/master/API.md)

Expand Down
2 changes: 1 addition & 1 deletion btc_toolkit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""btc-toolkit — Bitcoin CLI tools. Zero dependencies, no Bitcoin Core required."""

__version__ = "0.4.0"
__version__ = "0.5.0"
98 changes: 98 additions & 0 deletions btc_toolkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .balance import get_balance, AddressNotFoundError
from .fees import get_fees
from .block import get_block, BlockNotFoundError
from .utxo import get_utxos


BANNER = r"""
Expand Down Expand Up @@ -275,6 +276,80 @@ def _block_json(args: argparse.Namespace) -> int:
return 0


# ──────────────────────────────────────────────────────────────────────
# utxo subcommand
# ──────────────────────────────────────────────────────────────────────

def _cmd_utxo(args: argparse.Namespace) -> int:
if args.json_output:
return _utxo_json(args)

print(c.cyan(BANNER))
print(c.dim(f" btc-toolkit v{__version__} · utxo · Mempool.space API\n"))

addr_short = args.address if len(args.address) <= 24 else (
f"{args.address[:12]}...{args.address[-8:]}"
)
print(f" {c.bold('Address:')} {addr_short}")
print(f" {c.bold('Network:')} {args.network}")
print(f" {'─' * 48}\n")

try:
us = get_utxos(args.address, args.network, args.confirmed_only)
except AddressNotFoundError:
print(f" {c.red('✗')} Address not found.\n")
return 1
except MempoolAPIError as e:
print(f" {c.red('✗')} API error: {e}\n")
return 1
except ValueError as e:
print(f" {c.red('✗')} {e}\n")
return 1

if not us.utxos:
print(f" {c.yellow('⚠')} No UTXOs found for this address.\n")
return 0

label = "confirmed " if args.confirmed_only else ""
print(f" {c.green('✓')} {len(us.utxos)} {label}UTXO(s) · "
f"{c.bold(us.sats_to_btc(us.total_sats) + ' BTC')} total\n")

shown = us.utxos[: args.limit]
for u in shown:
txid_short = f"{u.txid[:12]}...{u.txid[-6:]}"
status = c.green("✓ confirmed") if u.confirmed else c.yellow("⧗ mempool")
height = f"#{u.block_height:,}" if u.block_height else "—"
print(f" ├─ {txid_short}:{u.vout}")
print(f" │ {us.sats_to_btc(u.value)} BTC ({u.value:,} sats) · "
f"{status} · {c.dim(height)}")

remaining = len(us.utxos) - len(shown)
if remaining > 0:
print(f" └─ {c.dim(f'… and {remaining} more (use --limit to show more)')}")
else:
print(f" └─ {c.dim('end')}")

print()
if us.unconfirmed_count and not args.confirmed_only:
print(f" {c.dim(f'Confirmed: {us.confirmed_count} · '
f'Mempool: {us.unconfirmed_count}')}")
print()
print(f" {c.dim(f'https://mempool.space/address/{args.address}')}\n")
return 0


def _utxo_json(args: argparse.Namespace) -> int:
try:
us = get_utxos(args.address, args.network, args.confirmed_only)
except (AddressNotFoundError, MempoolAPIError, ValueError) as e:
print(json.dumps({"error": str(e), "address": args.address}, indent=2))
return 1

output = {"network": args.network, **us.to_dict()}
print(json.dumps(output, indent=2))
return 0


# ──────────────────────────────────────────────────────────────────────
# argument parser
# ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -357,6 +432,29 @@ def build_parser() -> argparse.ArgumentParser:
)
p_blk.set_defaults(func=_cmd_block)

# utxo
p_utxo = subparsers.add_parser(
"utxo", help="List the unspent outputs (UTXOs) of an address."
)
p_utxo.add_argument("address", help="Bitcoin address (any type).")
p_utxo.add_argument(
"-n", "--network", choices=SUPPORTED_NETWORKS, default="mainnet",
help="Bitcoin network (default: mainnet).",
)
p_utxo.add_argument(
"--json", action="store_true", dest="json_output",
help="Output as JSON.",
)
p_utxo.add_argument(
"--confirmed-only", action="store_true",
help="Exclude unconfirmed (mempool) UTXOs.",
)
p_utxo.add_argument(
"--limit", type=int, default=15,
help="Max UTXOs to display (default: 15; JSON always shows all).",
)
p_utxo.set_defaults(func=_cmd_utxo)

return parser


Expand Down
126 changes: 126 additions & 0 deletions btc_toolkit/utxo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""
UTXO set inspector.

Lists the unspent transaction outputs (UTXOs) of a Bitcoin address
via the Mempool.space API.

Endpoint:
GET /api/address/:address/utxo
-> [ { txid, vout, value, status: { confirmed,
block_height?, block_hash?, block_time? } }, ... ]

Values are in satoshis.
"""

from dataclasses import dataclass

from .api import get_json, NotFoundError
from .balance import _validate_address, AddressNotFoundError, SATS_PER_BTC


@dataclass
class Utxo:
"""A single unspent transaction output."""

txid: str
vout: int
value: int
confirmed: bool
block_height: int | None

def to_dict(self) -> dict:
return {
"txid": self.txid,
"vout": self.vout,
"value_sats": self.value,
"confirmed": self.confirmed,
"block_height": self.block_height,
}


@dataclass
class UtxoSet:
"""The full UTXO set of an address, with aggregates."""

address: str
utxos: list[Utxo]

@property
def total_sats(self) -> int:
return sum(u.value for u in self.utxos)

@property
def confirmed_count(self) -> int:
return sum(1 for u in self.utxos if u.confirmed)

@property
def unconfirmed_count(self) -> int:
return sum(1 for u in self.utxos if not u.confirmed)

@staticmethod
def sats_to_btc(sats: int) -> str:
"""Format satoshis as a BTC string (integer arithmetic)."""
sign = "-" if sats < 0 else ""
sats = abs(sats)
return f"{sign}{sats // SATS_PER_BTC}.{sats % SATS_PER_BTC:08d}"

def to_dict(self) -> dict:
return {
"address": self.address,
"utxo_count": len(self.utxos),
"confirmed_count": self.confirmed_count,
"unconfirmed_count": self.unconfirmed_count,
"total": {
"sats": self.total_sats,
"btc": self.sats_to_btc(self.total_sats),
},
"utxos": [u.to_dict() for u in self.utxos],
}


def get_utxos(
address: str,
network: str = "mainnet",
confirmed_only: bool = False,
) -> UtxoSet:
"""
Fetch the UTXO set for a Bitcoin address.

Args:
address: The Bitcoin address (any type).
network: 'mainnet' or 'testnet'.
confirmed_only: If True, drop mempool (unconfirmed) UTXOs.

Returns:
A UtxoSet sorted by value, largest first.

Raises:
ValueError: If the address format is obviously invalid.
AddressNotFoundError: If the address is not found upstream.
MempoolAPIError: On other API errors.
"""
address = _validate_address(address)

try:
data = get_json(f"/address/{address}/utxo", network)
except NotFoundError as e:
raise AddressNotFoundError(f"Address not found: {address}") from e

utxos = []
for item in data:
status = item.get("status", {})
confirmed = status.get("confirmed", False)
if confirmed_only and not confirmed:
continue
utxos.append(
Utxo(
txid=item.get("txid", ""),
vout=item.get("vout", 0),
value=item.get("value", 0),
confirmed=confirmed,
block_height=status.get("block_height"),
)
)

utxos.sort(key=lambda u: u.value, reverse=True)
return UtxoSet(address=address, utxos=utxos)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "btc-toolkit"
version = "0.4.0"
version = "0.5.0"
description = "Bitcoin CLI toolkit — OP_RETURN decoder, balance checker, fee estimator & more. Zero deps, no Bitcoin Core required."
readme = "README.md"
license = {text = "MIT"}
Expand Down
Loading
Loading