Skip to content

jmwallet.wallet.rows

jmwallet.wallet.rows

Canonical row model for wallet info displays.

All wallet views (the static CLI info --extended today, the interactive explorer later) are fed from a single, view-independent row model. Renderers consume rows; they never re-derive visibility, labels or ordering from wallet internals (a second derivation path drifting from the real display logic was a bug source in earlier rework attempts).

Vocabulary (three levels, kept as separate row fields):

  • label: what the funds are (new, dep, cj-o, cj-c, chg, used, flag, rsvd, fb-a, fb-x). Short display forms (max 4 chars) keep the label column narrow; the underlying AddressStatus keeps the historic long names (persisted in wallet metadata), e.g. non-cj-change is surfaced as chg (see _STATUS_TO_LABEL).
  • state: spendability, derived per row from the frozen/pending flags and the confirmation count (spend/frozn/pend/ unconf; renderers show spend explicitly as a positive signal). Fidelity bonds carry their lock state in the label (fb-a/fb-x) and the locktime date as a separate data field (locktime, formatted via fb_date_str).
  • reused: independent flag. A reused cj-o is still a cj-o (label) that was reused (flag) — see issue #564.

Pipeline stages (deliberately separate, so later CLI/explorer filter and sort flags become thin layers over tested stages)::

build -> filter -> sort -> group -> render

The static CLI currently uses the identity filter and renders in build order; the sort stage with :func:base_order_key exists for the explorer and future flags.

Ordering note: the build stage preserves the input order of the address lists, which for external/internal branches is derivation-index order and for the fidelity bond branch is the service's locktime-then-index order (the meaningful chronological axis for bonds). base_order_key defines the canonical sorted order as (mixdepth, branch rank, derivation index, intra-address position); applied to the fidelity bond branch this can differ from the legacy locktime listing, which is acceptable because the static renderer does not sort.

Attributes

AddressRowLike: TypeAlias = UtxoRow | AddressRow module-attribute

DEFAULT_NEW_ADDRESS_LIMIT = 6 module-attribute

EXTERNAL_BRANCH = 0 module-attribute

GroupableRow: TypeAlias = UtxoRow | AddressRow | HiddenRow | BranchSummaryRow module-attribute

INTERNAL_BRANCH = 1 module-attribute

Row: TypeAlias = UtxoRow | AddressRow | HiddenRow | BranchSummaryRow | MixdepthSummaryRow module-attribute

RowLabel = Literal['new', 'dep', 'cj-o', 'cj-c', 'chg', 'used', 'flag', 'rsvd', 'fb-a', 'fb-x'] module-attribute

__all__ = ['DEFAULT_NEW_ADDRESS_LIMIT', 'EXTERNAL_BRANCH', 'INTERNAL_BRANCH', 'AddressRow', 'AddressRowLike', 'BranchSummaryRow', 'GroupableRow', 'HiddenRow', 'MixdepthSummaryRow', 'Row', 'RowLabel', 'UtxoRow', 'base_order_key', 'build_branch_rows', 'derive_label', 'fb_date_str', 'filter_rows', 'group_rows', 'sort_rows', 'summarize_branch', 'summarize_mixdepth'] module-attribute

Classes

AddressRow dataclass

One row for a visible address without UTXOs.

Covers new/rsvd (always shown) and, with show_empty, used/flag addresses. Also used for unfunded fidelity bond addresses (redeemed bonds stay visible, with their locktime). reused carries the RU flag for empty-but-reused addresses so renderers can still warn about them.

Source code in jmwallet/src/jmwallet/wallet/rows.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@dataclass
class AddressRow:
    """One row for a visible address without UTXOs.

    Covers ``new``/``rsvd`` (always shown) and, with ``show_empty``,
    ``used``/``flag`` addresses. Also used for unfunded fidelity
    bond addresses (redeemed bonds stay visible, with their locktime).
    ``reused`` carries the RU flag for empty-but-reused addresses so
    renderers can still warn about them.
    """

    mixdepth: int
    branch: int
    address_index: int
    path: str
    address: str
    label: RowLabel
    reused: bool
    pending: bool
    user_label: str
    locktime: int | None = None
    original_seq: int = 0
Attributes
address: str instance-attribute
address_index: int instance-attribute
branch: int instance-attribute
label: RowLabel instance-attribute
locktime: int | None = None class-attribute instance-attribute
mixdepth: int instance-attribute
original_seq: int = 0 class-attribute instance-attribute
path: str instance-attribute
pending: bool instance-attribute
reused: bool instance-attribute
user_label: str instance-attribute

BranchSummaryRow dataclass

Aggregated balance for one branch of one mixdepth.

Source code in jmwallet/src/jmwallet/wallet/rows.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@dataclass
class BranchSummaryRow:
    """Aggregated balance for one branch of one mixdepth."""

    mixdepth: int
    branch: int
    balance: int
    frozen_balance: int = 0
    locked_balance: int = 0  # fb-a funds (fidelity bond branch only)
    expired_balance: int = 0  # fb-x funds (fidelity bond branch only)
    expired_frozen_balance: int = 0  # frozen fb-x funds (fidelity bond branch only)

    @property
    def spendable(self) -> int:
        """Balance usable for automatic coin selection.

        Fidelity bond funds are excluded from normal automatic selection
        (locked ones cannot move; expired ones need explicit selection or
        a bond-only sweep, see :attr:`expired_spendable`), so the branch
        contributes 0, matching the legacy "(spendable: 0 sats)" display.
        """
        if self.branch == FIDELITY_BOND_BRANCH:
            return 0
        return self.balance - self.frozen_balance

    @property
    def expired_spendable(self) -> int:
        """Expired bond funds that are spendable right now.

        Frozen expired bonds are excluded: frozen means excluded from
        any transaction, so counting them would contradict the ``frozn``
        state shown on the row.
        """
        return self.expired_balance - self.expired_frozen_balance
Attributes
balance: int instance-attribute
branch: int instance-attribute
expired_balance: int = 0 class-attribute instance-attribute
expired_frozen_balance: int = 0 class-attribute instance-attribute
expired_spendable: int property

Expired bond funds that are spendable right now.

Frozen expired bonds are excluded: frozen means excluded from any transaction, so counting them would contradict the frozn state shown on the row.

frozen_balance: int = 0 class-attribute instance-attribute
locked_balance: int = 0 class-attribute instance-attribute
mixdepth: int instance-attribute
spendable: int property

Balance usable for automatic coin selection.

Fidelity bond funds are excluded from normal automatic selection (locked ones cannot move; expired ones need explicit selection or a bond-only sweep, see :attr:expired_spendable), so the branch contributes 0, matching the legacy "(spendable: 0 sats)" display.

HiddenRow dataclass

Count of empty addresses hidden by the gap/show-empty logic.

Source code in jmwallet/src/jmwallet/wallet/rows.py
135
136
137
138
139
140
141
@dataclass
class HiddenRow:
    """Count of empty addresses hidden by the gap/show-empty logic."""

    mixdepth: int
    branch: int
    hidden_count: int
Attributes
branch: int instance-attribute
hidden_count: int instance-attribute
mixdepth: int instance-attribute

MixdepthSummaryRow dataclass

Aggregated balance for one mixdepth (all branches incl. bonds).

Source code in jmwallet/src/jmwallet/wallet/rows.py
180
181
182
183
184
185
186
@dataclass
class MixdepthSummaryRow:
    """Aggregated balance for one mixdepth (all branches incl. bonds)."""

    mixdepth: int
    total_balance: int
    spendable_balance: int
Attributes
mixdepth: int instance-attribute
spendable_balance: int instance-attribute
total_balance: int instance-attribute

UtxoRow dataclass

One row per funded UTXO (hard rule: one line per UTXO).

Source code in jmwallet/src/jmwallet/wallet/rows.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
@dataclass
class UtxoRow:
    """One row per funded UTXO (hard rule: one line per UTXO)."""

    mixdepth: int
    branch: int
    address_index: int
    path: str
    address: str
    label: RowLabel
    reused: bool
    pending: bool
    user_label: str
    utxo: UTXOInfo
    locktime: int | None = None  # fidelity bond locktime; source of the fb-date
    original_seq: int = 0  # UTXO position within its address (cache order)

    @property
    def frozen(self) -> bool:
        """Spendability deviation: frozen UTXOs are excluded from coin selection."""
        return self.utxo.frozen

    @property
    def unconfirmed(self) -> bool:
        return self.utxo.confirmations == 0
Attributes
address: str instance-attribute
address_index: int instance-attribute
branch: int instance-attribute
frozen: bool property

Spendability deviation: frozen UTXOs are excluded from coin selection.

label: RowLabel instance-attribute
locktime: int | None = None class-attribute instance-attribute
mixdepth: int instance-attribute
original_seq: int = 0 class-attribute instance-attribute
path: str instance-attribute
pending: bool instance-attribute
reused: bool instance-attribute
unconfirmed: bool property
user_label: str instance-attribute
utxo: UTXOInfo instance-attribute

Functions:

base_order_key(row: AddressRowLike) -> tuple[int, int, int, int]

Canonical ordering: mixdepth, branch (external < internal < fidelity bonds), derivation index, then the UTXO's original position within its address (cache order).

Formalizes the order the legacy display produced for the external/internal branches. There is deliberately no txid/vout tiebreak: the intra-address cache order is the stable is-order.

Source code in jmwallet/src/jmwallet/wallet/rows.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def base_order_key(row: AddressRowLike) -> tuple[int, int, int, int]:
    """Canonical ordering: mixdepth, branch (external < internal < fidelity
    bonds), derivation index, then the UTXO's original position within its
    address (cache order).

    Formalizes the order the legacy display produced for the
    external/internal branches. There is deliberately no txid/vout
    tiebreak: the intra-address cache order is the stable is-order.
    """
    return (
        row.mixdepth,
        _BRANCH_SORT_RANK.get(row.branch, row.branch),
        row.address_index,
        row.original_seq,
    )

build_branch_rows(addresses: list[AddressInfo], *, mixdepth: int, branch: int, pending_addresses: set[str] | None = None, show_empty: bool = False, new_address_limit: int = DEFAULT_NEW_ADDRESS_LIMIT, now: int | None = None) -> list[Row]

Build the canonical rows for one branch of one mixdepth.

Produces one :class:UtxoRow per UTXO (hard rule: one line per UTXO), one :class:AddressRow per visible address without funds, and a single :class:HiddenRow when the gap/show-empty logic hid empty addresses. Rows are emitted in input order, which equals the legacy display order (derivation index for external/internal, locktime for fidelity bonds).

Visibility mirrors the legacy display exactly: reserved addresses are always shown; up to new_address_limit unused new addresses are shown; empty used-empty/flagged addresses are hidden. show_empty disables all hiding. The fidelity bond branch never hides anything (redeemed/unfunded bonds must stay visible).

Source code in jmwallet/src/jmwallet/wallet/rows.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def build_branch_rows(
    addresses: list[AddressInfo],
    *,
    mixdepth: int,
    branch: int,
    pending_addresses: set[str] | None = None,
    show_empty: bool = False,
    new_address_limit: int = DEFAULT_NEW_ADDRESS_LIMIT,
    now: int | None = None,
) -> list[Row]:
    """Build the canonical rows for one branch of one mixdepth.

    Produces one :class:`UtxoRow` per UTXO (hard rule: one line per UTXO),
    one :class:`AddressRow` per visible address without funds, and a single
    :class:`HiddenRow` when the gap/show-empty logic hid empty addresses.
    Rows are emitted in input order, which equals the legacy display order
    (derivation index for external/internal, locktime for fidelity bonds).

    Visibility mirrors the legacy display exactly: ``reserved`` addresses
    are always shown; up to ``new_address_limit`` unused ``new`` addresses
    are shown; empty ``used-empty``/``flagged`` addresses are hidden.
    ``show_empty`` disables all hiding. The fidelity bond branch never
    hides anything (redeemed/unfunded bonds must stay visible).
    """
    pending = pending_addresses or set()
    rows: list[Row] = []
    hidden = 0
    empty_new_shown = 0
    hide_empties = not show_empty and branch != FIDELITY_BOND_BRANCH

    for addr in addresses:
        if hide_empties and addr.balance == 0:
            if addr.status == "reserved":
                # Set-aside/handed-out addresses are always shown (with
                # their label) so the user can see what they gave out.
                pass
            elif addr.status == "new" and empty_new_shown < new_address_limit:
                empty_new_shown += 1
            else:
                # used-empty/flagged and surplus new addresses are noise.
                hidden += 1
                continue

        label, reused = derive_label(addr, now=now)

        if addr.utxos:
            for seq, utxo in enumerate(addr.utxos):
                rows.append(
                    UtxoRow(
                        mixdepth=mixdepth,
                        branch=branch,
                        address_index=addr.index,
                        path=addr.path,
                        address=addr.address,
                        label=label,
                        reused=reused,
                        pending=addr.address in pending,
                        user_label=addr.label,
                        utxo=utxo,
                        locktime=addr.locktime,
                        original_seq=seq,
                    )
                )
        else:
            rows.append(
                AddressRow(
                    mixdepth=mixdepth,
                    branch=branch,
                    address_index=addr.index,
                    path=addr.path,
                    address=addr.address,
                    label=label,
                    reused=reused,
                    pending=addr.address in pending,
                    user_label=addr.label,
                    locktime=addr.locktime,
                )
            )

    if hidden:
        rows.append(HiddenRow(mixdepth=mixdepth, branch=branch, hidden_count=hidden))
    return rows

derive_label(addr: AddressInfo, *, now: int | None = None) -> tuple[RowLabel, bool]

Translate an AddressInfo into the three-level row vocabulary.

Returns (label, reused):

  • status == "reused" → (base_status, True). base_status is always set in that case (issue #564); defensively falls back to dep/chg by branch if it is missing or unusable.
  • bond addresses → fb-a until now strictly passes the locktime, fb-x once strictly past it (the lock state lives in the label; the locktime date is a separate row field).
  • every other status maps onto its label via _STATUS_TO_LABEL.

The CLI supplies chain median-time-past as now; standalone display callers default to host time.

Source code in jmwallet/src/jmwallet/wallet/rows.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def derive_label(addr: AddressInfo, *, now: int | None = None) -> tuple[RowLabel, bool]:
    """Translate an ``AddressInfo`` into the three-level row vocabulary.

    Returns ``(label, reused)``:

    - ``status == "reused"`` → ``(base_status, True)``. ``base_status`` is
      always set in that case (issue #564); defensively falls back to
      ``dep``/``chg`` by branch if it is missing or unusable.
    - bond addresses → ``fb-a`` until ``now`` strictly passes the locktime,
      ``fb-x`` once strictly past it (the lock state lives in the label; the
      locktime date is a separate row field).
    - every other status maps onto its label via ``_STATUS_TO_LABEL``.

    The CLI supplies chain median-time-past as ``now``; standalone display
    callers default to host time.
    """
    if addr.is_bond or addr.status == "bond":
        if now is None:
            now = int(time.time())
        locked = addr.locktime is not None and addr.locktime >= now
        return ("fb-a" if locked else "fb-x"), False
    if addr.status == "reused":
        base = addr.base_status
        if base is None or base not in _STATUS_TO_LABEL:
            base = "deposit" if addr.is_external else "non-cj-change"
        return _STATUS_TO_LABEL[base], True
    return _STATUS_TO_LABEL.get(addr.status, "flag"), False

fb_date_str(locktime: int) -> str

Format a fidelity bond locktime as YY-MM-DD (local time).

Source code in jmwallet/src/jmwallet/wallet/rows.py
243
244
245
def fb_date_str(locktime: int) -> str:
    """Format a fidelity bond locktime as YY-MM-DD (local time)."""
    return datetime.fromtimestamp(locktime).strftime("%y-%m-%d")

filter_rows(rows: list[Row], predicate: Callable[[Row], bool] | None = None) -> list[Row]

Pipeline filter stage.

Identity when no predicate is given — the static CLI has no filters yet; the interactive explorer (and possible future CLI flags) will pass one.

Source code in jmwallet/src/jmwallet/wallet/rows.py
332
333
334
335
336
337
338
339
340
def filter_rows(rows: list[Row], predicate: Callable[[Row], bool] | None = None) -> list[Row]:
    """Pipeline filter stage.

    Identity when no predicate is given — the static CLI has no filters yet;
    the interactive explorer (and possible future CLI flags) will pass one.
    """
    if predicate is None:
        return list(rows)
    return [row for row in rows if predicate(row)]

group_rows(rows: list[GroupableRow]) -> dict[tuple[int, int], list[GroupableRow]]

Pipeline grouping stage: bucket rows by (mixdepth, branch), preserving their order within each bucket.

Source code in jmwallet/src/jmwallet/wallet/rows.py
377
378
379
380
381
382
383
def group_rows(rows: list[GroupableRow]) -> dict[tuple[int, int], list[GroupableRow]]:
    """Pipeline grouping stage: bucket rows by ``(mixdepth, branch)``,
    preserving their order within each bucket."""
    groups: dict[tuple[int, int], list[GroupableRow]] = {}
    for row in rows:
        groups.setdefault((row.mixdepth, row.branch), []).append(row)
    return groups

sort_rows(rows: list[AddressRowLike], key: Callable[[AddressRowLike], Any] | None = None) -> list[AddressRowLike]

Pipeline sort stage.

Without a key, sorts into :func:base_order_key order. With a user key (explorer/future CLI flags), rows are first brought into base order so ties under the user key fall back to the stable base order; the key is expected to keep address groups together (renderer concern).

Source code in jmwallet/src/jmwallet/wallet/rows.py
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
def sort_rows(
    rows: list[AddressRowLike],
    key: Callable[[AddressRowLike], Any] | None = None,
) -> list[AddressRowLike]:
    """Pipeline sort stage.

    Without a key, sorts into :func:`base_order_key` order. With a user key
    (explorer/future CLI flags), rows are first brought into base order so
    ties under the user key fall back to the stable base order; the key is
    expected to keep address groups together (renderer concern).
    """
    ordered = sorted(rows, key=base_order_key)
    if key is None:
        return ordered
    return sorted(ordered, key=key)

summarize_branch(rows: list[Row], *, mixdepth: int, branch: int, now: int | None = None) -> BranchSummaryRow

Aggregate the rows of a single branch into a :class:BranchSummaryRow.

Expects the rows produced by one :func:build_branch_rows call. Hidden rows contribute nothing (hidden addresses are empty by construction). For the fidelity bond branch, funds are additionally split into locked (locktime in the future) and expired, and the frozen share of the expired funds is tracked separately so the spendable (explicit) figure can exclude it.

Source code in jmwallet/src/jmwallet/wallet/rows.py
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def summarize_branch(
    rows: list[Row], *, mixdepth: int, branch: int, now: int | None = None
) -> BranchSummaryRow:
    """Aggregate the rows of a single branch into a :class:`BranchSummaryRow`.

    Expects the rows produced by one :func:`build_branch_rows` call.
    Hidden rows contribute nothing (hidden addresses are empty by
    construction). For the fidelity bond branch, funds are additionally
    split into locked (locktime in the future) and expired, and the
    frozen share of the expired funds is tracked separately so the
    spendable (explicit) figure can exclude it.
    """
    if now is None:
        now = int(time.time())
    balance = 0
    frozen = 0
    locked = 0
    expired = 0
    expired_frozen = 0
    for row in rows:
        if not isinstance(row, UtxoRow):
            continue
        balance += row.utxo.value
        if row.utxo.frozen:
            frozen += row.utxo.value
        if branch == FIDELITY_BOND_BRANCH and row.locktime is not None:
            if row.locktime >= now:
                locked += row.utxo.value
            else:
                expired += row.utxo.value
                if row.utxo.frozen:
                    expired_frozen += row.utxo.value
    return BranchSummaryRow(
        mixdepth=mixdepth,
        branch=branch,
        balance=balance,
        frozen_balance=frozen,
        locked_balance=locked,
        expired_balance=expired,
        expired_frozen_balance=expired_frozen,
    )

summarize_mixdepth(branches: list[BranchSummaryRow], *, mixdepth: int) -> MixdepthSummaryRow

Aggregate branch summaries into the mixdepth total.

The total includes fidelity bond funds; the spendable figure excludes them (their branch summary reports 0 spendable).

Source code in jmwallet/src/jmwallet/wallet/rows.py
429
430
431
432
433
434
435
436
437
438
439
def summarize_mixdepth(branches: list[BranchSummaryRow], *, mixdepth: int) -> MixdepthSummaryRow:
    """Aggregate branch summaries into the mixdepth total.

    The total includes fidelity bond funds; the spendable figure excludes
    them (their branch summary reports 0 spendable).
    """
    return MixdepthSummaryRow(
        mixdepth=mixdepth,
        total_balance=sum(b.balance for b in branches),
        spendable_balance=sum(b.spendable for b in branches),
    )