diff --git a/README.md b/README.md
index f732301..1a5082c 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Query the Bitcoin network directly via the [Mempool.space](https://mempool.space
| `btc-toolkit balance
` | Confirmed + unconfirmed balance of any address | ✅ Phase 2 |
| `btc-toolkit fees` | Recommended fee rates + mempool backlog | ✅ Phase 3 |
| `btc-toolkit block ` | Block metadata by height, hash, or latest | ✅ Phase 4 |
-| `btc-toolkit utxo ` | UTXO set inspector | Phase 5 |
+| `btc-toolkit utxo ` | Unspent outputs of any address | ✅ Phase 5 |
## Installation
@@ -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 --confirmed-only
+
+# Show more than 15 entries
+btc-toolkit utxo --limit 50
+
+# JSON output (always includes all UTXOs)
+btc-toolkit utxo --json
+```
+
### opreturn — decode embedded messages
```bash
@@ -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
@@ -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.

@@ -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
@@ -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/`
- Fee data: `https://mempool.space/api/v1/fees/recommended`
- Block data: `https://mempool.space/api/block/`
+- UTXO data: `https://mempool.space/api/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)
diff --git a/btc_toolkit/__init__.py b/btc_toolkit/__init__.py
index f680986..2f6099a 100644
--- a/btc_toolkit/__init__.py
+++ b/btc_toolkit/__init__.py
@@ -1,3 +1,3 @@
"""btc-toolkit — Bitcoin CLI tools. Zero dependencies, no Bitcoin Core required."""
-__version__ = "0.4.0"
+__version__ = "0.5.0"
diff --git a/btc_toolkit/cli.py b/btc_toolkit/cli.py
index e3e9fc7..a62799f 100644
--- a/btc_toolkit/cli.py
+++ b/btc_toolkit/cli.py
@@ -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"""
@@ -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
# ──────────────────────────────────────────────────────────────────────
@@ -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
diff --git a/btc_toolkit/utxo.py b/btc_toolkit/utxo.py
new file mode 100644
index 0000000..5bb3078
--- /dev/null
+++ b/btc_toolkit/utxo.py
@@ -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)
diff --git a/pyproject.toml b/pyproject.toml
index a227f63..7d599c4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"}
diff --git a/tests/test_utxo.py b/tests/test_utxo.py
new file mode 100644
index 0000000..18cd461
--- /dev/null
+++ b/tests/test_utxo.py
@@ -0,0 +1,94 @@
+"""Tests for the UTXO set inspector."""
+
+import unittest
+from unittest.mock import patch
+
+from btc_toolkit.utxo import get_utxos, Utxo, UtxoSet
+
+ADDR = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
+
+SAMPLE_UTXOS = [
+ {
+ "txid": "a" * 64,
+ "vout": 0,
+ "value": 50_000,
+ "status": {"confirmed": True, "block_height": 900000,
+ "block_hash": "b" * 64, "block_time": 1700000000},
+ },
+ {
+ "txid": "c" * 64,
+ "vout": 1,
+ "value": 150_000_000,
+ "status": {"confirmed": True, "block_height": 899999,
+ "block_hash": "d" * 64, "block_time": 1699999000},
+ },
+ {
+ "txid": "e" * 64,
+ "vout": 0,
+ "value": 25_000,
+ "status": {"confirmed": False},
+ },
+]
+
+
+class TestGetUtxos(unittest.TestCase):
+ @patch("btc_toolkit.utxo.get_json")
+ def test_full_set(self, mock_get):
+ mock_get.return_value = SAMPLE_UTXOS
+ us = get_utxos(ADDR)
+ self.assertEqual(len(us.utxos), 3)
+ self.assertEqual(us.confirmed_count, 2)
+ self.assertEqual(us.unconfirmed_count, 1)
+ self.assertEqual(us.total_sats, 150_075_000)
+
+ @patch("btc_toolkit.utxo.get_json")
+ def test_sorted_largest_first(self, mock_get):
+ mock_get.return_value = SAMPLE_UTXOS
+ us = get_utxos(ADDR)
+ values = [u.value for u in us.utxos]
+ self.assertEqual(values, sorted(values, reverse=True))
+ self.assertEqual(us.utxos[0].value, 150_000_000)
+
+ @patch("btc_toolkit.utxo.get_json")
+ def test_confirmed_only_filter(self, mock_get):
+ mock_get.return_value = SAMPLE_UTXOS
+ us = get_utxos(ADDR, confirmed_only=True)
+ self.assertEqual(len(us.utxos), 2)
+ self.assertEqual(us.unconfirmed_count, 0)
+ self.assertEqual(us.total_sats, 150_050_000)
+
+ @patch("btc_toolkit.utxo.get_json")
+ def test_empty_set(self, mock_get):
+ mock_get.return_value = []
+ us = get_utxos(ADDR)
+ self.assertEqual(len(us.utxos), 0)
+ self.assertEqual(us.total_sats, 0)
+
+ @patch("btc_toolkit.utxo.get_json")
+ def test_unconfirmed_has_no_height(self, mock_get):
+ mock_get.return_value = SAMPLE_UTXOS
+ us = get_utxos(ADDR)
+ unconfirmed = [u for u in us.utxos if not u.confirmed][0]
+ self.assertIsNone(unconfirmed.block_height)
+
+ @patch("btc_toolkit.utxo.get_json")
+ def test_to_dict_structure(self, mock_get):
+ mock_get.return_value = SAMPLE_UTXOS
+ d = get_utxos(ADDR).to_dict()
+ self.assertEqual(d["utxo_count"], 3)
+ self.assertEqual(d["total"]["sats"], 150_075_000)
+ self.assertEqual(d["total"]["btc"], "1.50075000")
+ self.assertEqual(len(d["utxos"]), 3)
+ self.assertNotIn("block_hash", d["utxos"][0])
+
+ def test_invalid_address_raises(self):
+ with self.assertRaises(ValueError):
+ get_utxos("")
+
+ def test_sats_to_btc(self):
+ self.assertEqual(UtxoSet.sats_to_btc(150_075_000), "1.50075000")
+ self.assertEqual(UtxoSet.sats_to_btc(0), "0.00000000")
+
+
+if __name__ == "__main__":
+ unittest.main()