gabriel / muse public
btc_utxos.py python
149 lines 5.7 KB
15cf97e9 feat(bitcoin): add semantic porcelain layer — 19 Bitcoin-idiomatic CLI … Gabriel Cardona <gabriel@tellurstori.com> 3d ago
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 )