From 9c3e9af524e557d25982520ed327def4fc9ee375 Mon Sep 17 00:00:00 2001 From: devdavidejesus Date: Sat, 25 Jul 2026 23:35:48 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20=E2=80=94=20UTXO=20Set=20In?= =?UTF-8?q?spector=20(roadmap=20complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New 'btc-toolkit utxo
' subcommand: lists unspent outputs sorted by value, with confirmation status and block height - --confirmed-only filter, --limit display cap, --json full output - Reuses balance.py address validation and sats->BTC conversion - 8 new tests (62 total), all API calls mocked - Verified against live mainnet (104-UTXO address, totals consistent) - Documented known limitation: huge-UTXO addresses (e.g. genesis) exceed upstream electrs limit (HTTP 400) - Version bump to 0.5.0 — all 5 roadmap phases complete --- README.md | 36 ++++++++++-- btc_toolkit/__init__.py | 2 +- btc_toolkit/cli.py | 98 +++++++++++++++++++++++++++++++ btc_toolkit/utxo.py | 126 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_utxo.py | 94 ++++++++++++++++++++++++++++++ 6 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 btc_toolkit/utxo.py create mode 100644 tests/test_utxo.py 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. ![Tests passing](assets/tests.png) @@ -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()