btc_utxos.py
python
| 1 | """muse bitcoin utxos — full UTXO set with lifecycle analysis. |
| 2 | |
| 3 | Lists every UTXO in the versioned wallet with rich annotations: age in blocks, |
| 4 | effective value after estimated spend fee, dust flag, label, and category. |
| 5 | |
| 6 | Usage:: |
| 7 | |
| 8 | muse bitcoin utxos |
| 9 | muse bitcoin utxos --commit HEAD~3 |
| 10 | muse bitcoin utxos --fee-rate 20 --sort-by effective-value |
| 11 | muse bitcoin utxos --json |
| 12 | |
| 13 | Output:: |
| 14 | |
| 15 | UTXOs — working tree (6 UTXOs · 0.12340000 BTC spendable · fee 10 sat/vbyte) |
| 16 | |
| 17 | UTXO Amount Age(blk) Eff.Value Label Category |
| 18 | ────────────────────────────────────────────────────────────────────────────────────────── |
| 19 | abc...0:0 p2wpkh ✓ 1.00000000 BTC 12340 0.99999590 BTC cold wallet cold storage |
| 20 | def...1:0 p2tr ✓ 0.12340000 BTC 6170 0.12339942 BTC DCA stack income |
| 21 | ghi...2:0 p2wpkh ⏳ 546 sats 0 96 sats (none) unknown |
| 22 | """ |
| 23 | |
| 24 | from __future__ import annotations |
| 25 | |
| 26 | import json |
| 27 | import logging |
| 28 | |
| 29 | import typer |
| 30 | |
| 31 | from muse.core.errors import ExitCode |
| 32 | from muse.core.repo import require_repo |
| 33 | from muse.core.store import resolve_commit_ref |
| 34 | from muse.plugins.bitcoin._loader import ( |
| 35 | load_fees, |
| 36 | load_fees_from_workdir, |
| 37 | load_labels, |
| 38 | load_labels_from_workdir, |
| 39 | load_utxos, |
| 40 | load_utxos_from_workdir, |
| 41 | read_current_branch, |
| 42 | read_repo_id, |
| 43 | ) |
| 44 | from muse.plugins.bitcoin._query import ( |
| 45 | confirmed_balance_sat, |
| 46 | effective_value_sat, |
| 47 | format_sat, |
| 48 | is_dust, |
| 49 | latest_fee_estimate, |
| 50 | utxo_key, |
| 51 | ) |
| 52 | from muse.plugins.bitcoin._analytics import utxo_lifecycle |
| 53 | |
| 54 | logger = logging.getLogger(__name__) |
| 55 | app = typer.Typer() |
| 56 | |
| 57 | _SORT_KEYS = ("amount", "age", "effective-value", "confirmations") |
| 58 | |
| 59 | |
| 60 | @app.callback(invoke_without_command=True) |
| 61 | def utxos( |
| 62 | ctx: typer.Context, |
| 63 | ref: str | None = typer.Option(None, "--commit", "-c", metavar="REF", |
| 64 | help="Read from a historical commit."), |
| 65 | fee_rate: int = typer.Option(10, "--fee-rate", "-f", metavar="SAT/VBYTE", |
| 66 | help="Fee rate for effective-value and dust calculation."), |
| 67 | sort_by: str = typer.Option("amount", "--sort-by", "-s", |
| 68 | help="Sort key: amount | age | effective-value | confirmations."), |
| 69 | as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."), |
| 70 | ) -> None: |
| 71 | """List every UTXO with economic and provenance annotations. |
| 72 | |
| 73 | For each UTXO shows: the canonical key (txid:vout), script type, maturity |
| 74 | status, amount, age in blocks, effective value after estimated miner fee, |
| 75 | label, and category from the versioned label registry. |
| 76 | |
| 77 | ``--fee-rate`` recalculates effective value and dust status at any hypothetical |
| 78 | fee rate without touching on-chain state. Agents use this to pre-screen |
| 79 | UTXOs before coin selection. |
| 80 | """ |
| 81 | root = require_repo() |
| 82 | commit_label = "working tree" |
| 83 | |
| 84 | if ref is not None: |
| 85 | repo_id = read_repo_id(root) |
| 86 | branch = read_current_branch(root) |
| 87 | commit = resolve_commit_ref(root, repo_id, branch, ref) |
| 88 | if commit is None: |
| 89 | typer.echo(f"❌ Commit '{ref}' not found.", err=True) |
| 90 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 91 | utxo_list = load_utxos(root, commit.commit_id) |
| 92 | labels = load_labels(root, commit.commit_id) |
| 93 | fees_list = load_fees(root, commit.commit_id) |
| 94 | commit_label = commit.commit_id[:8] |
| 95 | else: |
| 96 | utxo_list = load_utxos_from_workdir(root) |
| 97 | labels = load_labels_from_workdir(root) |
| 98 | fees_list = load_fees_from_workdir(root) |
| 99 | |
| 100 | # Use oracle fee rate if not overridden by the caller |
| 101 | fee_est = latest_fee_estimate(fees_list) |
| 102 | effective_rate = fee_rate if fee_rate != 10 else ( |
| 103 | fee_est["target_6_block_sat_vbyte"] if fee_est else 10 |
| 104 | ) |
| 105 | |
| 106 | lifecycles = [utxo_lifecycle(u, labels, effective_rate) for u in utxo_list] |
| 107 | |
| 108 | # Sorting |
| 109 | sort_key = sort_by.lower() |
| 110 | if sort_key == "age": |
| 111 | lifecycles.sort(key=lambda lc: lc["age_blocks"] or 0, reverse=True) |
| 112 | elif sort_key == "effective-value": |
| 113 | lifecycles.sort(key=lambda lc: lc["effective_value_sat"], reverse=True) |
| 114 | elif sort_key == "confirmations": |
| 115 | lifecycles.sort(key=lambda lc: lc["confirmations"], reverse=True) |
| 116 | else: |
| 117 | lifecycles.sort(key=lambda lc: lc["amount_sat"], reverse=True) |
| 118 | |
| 119 | spendable = confirmed_balance_sat(utxo_list) |
| 120 | |
| 121 | if as_json: |
| 122 | typer.echo(json.dumps({ |
| 123 | "commit": commit_label, |
| 124 | "fee_rate_sat_vbyte": effective_rate, |
| 125 | "utxo_count": len(lifecycles), |
| 126 | "spendable_sat": spendable, |
| 127 | "utxos": [dict(lc) for lc in lifecycles], |
| 128 | }, indent=2)) |
| 129 | return |
| 130 | |
| 131 | typer.echo(f"\nUTXOs — {commit_label} " |
| 132 | f"({len(lifecycles)} UTXOs · {format_sat(spendable)} spendable " |
| 133 | f"· fee {effective_rate} sat/vbyte)\n") |
| 134 | |
| 135 | header = f" {'UTXO':<20} {'Type':<8} {'St':>2} {'Amount':>22} {'Age':>8} {'Eff.Value':>22} {'Label':<16} Category" |
| 136 | typer.echo(header) |
| 137 | typer.echo(" " + "─" * (len(header) + 2)) |
| 138 | |
| 139 | for lc in lifecycles: |
| 140 | key_short = lc["key"][:16] + "…" if len(lc["key"]) > 16 else lc["key"] |
| 141 | status = "⏳" if not lc["is_spendable"] else ("💀" if lc["is_dust"] else "✓") |
| 142 | age_str = str(lc["age_blocks"]) if lc["age_blocks"] is not None else "unconf" |
| 143 | label_str = (lc["label"] or "(none)")[:16] |
| 144 | cat_str = lc["category"][:12] |
| 145 | typer.echo( |
| 146 | f" {key_short:<20} {lc['script_type']:<8} {status:>2} " |
| 147 | f"{format_sat(lc['amount_sat']):>22} {age_str:>8} " |
| 148 | f"{format_sat(lc['effective_value_sat']):>22} {label_str:<16} {cat_str}" |
| 149 | ) |