Skip to content

z_getbalances/getbalances: transparent regular balance includes coinbase; coinbase field never populated; total.spendable ignores transparent #635

Description

@pacu

Summary

z_getbalances / getbalances reports transparent balance figures that are internally inconsistent with their own documented contract, and every RPC-visible balance number ends up either including un-spendable coinbase funds or omitting the transparent pool entirely. There is no RPC that correctly answers "how much can I currently pay to a transparent recipient?", which makes automated payout flows (e.g. mining pools) fail unpredictably.

Details

TransparentBalance in zallet-core/src/components/json_rpc/methods/get_balances.rs documents:

struct TransparentBalance {
    /// The transparent balance excluding coinbase outputs.
    regular: Option<Balance>,
    /// The transparent balance in coinbase outputs.
    /// Omitted if no coinbase funds are present.
    coinbase: Option<Balance>,
}

But the implementation (same file, L180-212) does not honor this:

// TODO: Separate out transparent coinbase and standalone balances.
let transparent_regular = account.unshielded_balance();
...
Ok(Some(TransparentBalance {
    regular: opt_balance_from(regular)?,
    coinbase: None, // never populated
}))

regular is populated from AccountBalance::unshielded_balance(), which is the entire transparent balance including coinbase outputs — contradicting the doc comment. coinbase is hardcoded to None and is never populated regardless of actual coinbase holdings.

Separately, total.spendable (from balance_fromAccountBalance::spendable_value()) only covers the shielded pools, so the transparent balance never contributes to total at all.

Reproduction

Environment: Zallet 0.1.0-beta.1, zebra backend, Zebra v28, mainnet, mining pool wallet with coinbase paid to a wallet-owned t-address.

Every transparent UTXO was independently classified against the node (getrawtransaction <txid> 1vin[0].coinbase): two coinbase outputs of 1.25 ZEC each, plus one non-coinbase 1.24035 ZEC output (from a prior z_sendmany z→t). The real spendable-to-transparent balance is therefore 1.24035 ZEC.

z_getbalances reported:

"transparent": { "regular": { "spendable": { "valueZat": 874035000 } } }
"sapling":     { "spendable": { "valueZat": 9375920000 } }
"total":       { "spendable": { "valueZat": 9375920000 } }
  • regular = 8.74035 ZEC — the two coinbase outputs are folded in; the true non-coinbase balance is 1.24035 ZEC.
  • No coinbase key is present at all.
  • total.spendable is byte-identical to sapling.spendable — transparent contributes 0.

Reproduced on a second sample: transparent balance 102.49 ZEC, sapling 0.00985 ZEC → total.spendable reported as exactly 985000 zats (i.e. only the sapling balance).

Impact

Since coinbase outputs can only be shielded, never spent transparently, every currently available balance figure is wrong for the "can I pay this transparent recipient" decision:

  • getwalletinfo is a stub (rpc: Implement getwalletinfo #55).
  • z_gettotalbalance.transparent and z_getbalances.transparent.regular both include coinbase.
  • total.spendable ignores the transparent pool entirely.

Acting on any of these numbers and calling z_sendmany fails with Insufficient balance (have 0, need N including fee). This is what took down payouts on our pool: z_getbalances reported 102.49 ZEC available, the pool attempted to pay out, and z_sendmany immediately failed with have 0, halting the payout cycle.

Current workaround

Enumerate z_listunspent, then call getrawtransaction on every txid to identify and exclude coinbase outputs — one extra node round-trip per transaction, per payout cycle. z_listunspent itself carries no coinbase marker (zcashd's listunspent exposed a generated field for this).

Suggested fix

Either of:

  1. Finish the // TODO: Separate out transparent coinbase and standalone balances. in get_balances.rs — likely needs AccountBalance (upstream, in zcash_client_backend) to carry the coinbase/non-coinbase split so regular/coinbase can be populated correctly, and total.spendable to include the non-coinbase transparent balance.
  2. Lower-cost for integrators in the meantime: add a generated/coinbase flag to z_listunspent output entries, mirroring zcashd's listunspent, so callers can filter without an extra getrawtransaction round-trip per UTXO.

Reported by a mining pool operator running Zallet in production.

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-bugCategory: This is a bug

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions