Skip to content

jmwallet.utxo_selector

jmwallet.utxo_selector

Interactive UTXO selector TUI.

Shows every UTXO in the wallet grouped by mixdepth (same layout as the freeze manager) so the user can compare coins across the whole wallet before picking which one(s) to spend.

A spend can only draw from a single mixdepth, so the selector pins the source mixdepth to the first selected UTXO: while anything is selected, UTXOs in other mixdepths render as unselectable [-] rows. Deselecting everything unpins the mixdepth again. Callers that already know the source mixdepth can pass allowed_mixdepth to pin it up front (other mixdepths are then shown for context only).

Attributes

Classes

Functions:

format_utxo_line(utxo: UTXOInfo, max_width: int = 120, prev_address: str = '', excluded_outpoints: set[tuple[str, int]] | None = None, term_width: int = 120, *, allowed_mixdepth: int | None = None, min_confirmations: int = 0, columns: list[tuple[str, int]] | None = None) -> str

Format a single UTXO row with separate Label and State columns.

Source code in jmwallet/src/jmwallet/utxo_selector.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def format_utxo_line(
    utxo: UTXOInfo,
    max_width: int = 120,
    prev_address: str = "",
    excluded_outpoints: set[tuple[str, int]] | None = None,
    term_width: int = 120,
    *,
    allowed_mixdepth: int | None = None,
    min_confirmations: int = 0,
    columns: list[tuple[str, int]] | None = None,
) -> str:
    """Format a single UTXO row with separate Label and State columns."""
    columns = columns or _table_columns(term_width, [utxo])
    addr_col_width = dict(columns).get("Address", 12)

    # All addresses use the calculated column width.
    if utxo.address == prev_address:
        addr_str = "..."
    else:
        if len(utxo.address) > addr_col_width:
            # Middle-ellipsis: show start...end
            prefix_len = (addr_col_width - 3) // 2
            suffix_len = addr_col_width - 3 - prefix_len
            addr_str = utxo.address[:prefix_len] + "..." + utxo.address[-suffix_len:]
        else:
            addr_str = utxo.address

    # Label column: FB status for fidelity bonds; remap "non-cj-change" to the
    # shorter "reg-change" so it fits the 11-char column.
    if utxo.is_fidelity_bond:
        label_col = "FB-active" if utxo.is_locked else "FB-expired"
    else:
        label_col = "reg-change" if utxo.label == "non-cj-change" else (utxo.label or "")

    values = {
        "MD": f"m{utxo.mixdepth}",
        "Address": addr_str,
        "Amount": f"{utxo.value:,} sats",
        "Amount (sats)": f"{utxo.value:,}",
        "Confs": f"{utxo.confirmations:,}",
        "Outpoint": f"{utxo.txid[:8]}...:{utxo.vout}",
        "Label": label_col,
        "State": _utxo_state_col(utxo, excluded_outpoints, allowed_mixdepth, min_confirmations),
    }
    return _clip(
        _format_cells(values, [(name, size) for name, size in columns if name != "Sel"]) + " |",
        max_width,
    )

select_utxos_interactive(utxos: list[UTXOInfo], target_amount: int = 0, allowed_mixdepth: int | None = None, min_confirmations: int = 0, excluded_outpoints: set[tuple[str, int]] | None = None) -> list[UTXOInfo]

Display an interactive UTXO selector over the whole wallet.

UTXOs are grouped by mixdepth (freeze-manager layout). Selection is limited to a single mixdepth: the first toggled UTXO pins the source mixdepth until everything is deselected again.

Keys: - Up/Down or j/k: Navigate - PgUp/PgDn: Page up/down - Tab/Space: Toggle selection - Enter: Confirm selection - q/Escape: Cancel - s: Select all (in the pinned/cursor mixdepth) - d: Deselect all - g/G: Go to top/bottom

Args: utxos: List of available UTXOs to choose from (any mixdepth) target_amount: Target amount in sats (0 for sweep, used for display) allowed_mixdepth: When set, restrict selection to this mixdepth (other mixdepths are displayed for context only) min_confirmations: UTXOs below this many confirmations are shown but unselectable excluded_outpoints: In-flight CoinJoin inputs shown as State in-use with a [-] mark; unavailable for selection.

Returns: List of selected UTXOs (all from one mixdepth), empty if cancelled

Raises: RuntimeError: If not running in a terminal

Source code in jmwallet/src/jmwallet/utxo_selector.py
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
def select_utxos_interactive(
    utxos: list[UTXOInfo],
    target_amount: int = 0,
    allowed_mixdepth: int | None = None,
    min_confirmations: int = 0,
    excluded_outpoints: set[tuple[str, int]] | None = None,
) -> list[UTXOInfo]:
    """Display an interactive UTXO selector over the whole wallet.

    UTXOs are grouped by mixdepth (freeze-manager layout). Selection is
    limited to a single mixdepth: the first toggled UTXO pins the source
    mixdepth until everything is deselected again.

    Keys:
    - Up/Down or j/k: Navigate
    - PgUp/PgDn: Page up/down
    - Tab/Space: Toggle selection
    - Enter: Confirm selection
    - q/Escape: Cancel
    - s: Select all (in the pinned/cursor mixdepth)
    - d: Deselect all
    - g/G: Go to top/bottom

    Args:
        utxos: List of available UTXOs to choose from (any mixdepth)
        target_amount: Target amount in sats (0 for sweep, used for display)
        allowed_mixdepth: When set, restrict selection to this mixdepth
            (other mixdepths are displayed for context only)
        min_confirmations: UTXOs below this many confirmations are shown
            but unselectable
        excluded_outpoints: In-flight CoinJoin inputs shown as State ``in-use``
            with a ``[-]`` mark; unavailable for selection.

    Returns:
        List of selected UTXOs (all from one mixdepth), empty if cancelled

    Raises:
        RuntimeError: If not running in a terminal
    """
    # Handle trivial cases without requiring a terminal
    if not utxos:
        return []
    excluded_outpoints = excluded_outpoints or set()

    # For multiple UTXOs, we need a terminal
    if not sys.stdin.isatty() or not sys.stdout.isatty():
        # If only one UTXO and no terminal, auto-select it (only if selectable)
        if len(utxos) == 1:
            utxo = utxos[0]
            if not _is_base_selectable(
                utxo, allowed_mixdepth, min_confirmations, excluded_outpoints
            ):
                return []
            return utxos
        raise RuntimeError("Interactive UTXO selection requires a terminal")

    # Sort UTXOs by derivation path (same order as freeze manager) and add separators
    sorted_utxos = sorted(utxos, key=lambda u: u.path)
    display_items = build_display_items(sorted_utxos)

    return curses.wrapper(
        _run_selector,
        display_items,
        target_amount,
        allowed_mixdepth,
        min_confirmations,
        excluded_outpoints,
    )