Skip to content

feat(gl-sdk): NodeState snapshot with UTXO-safe balance accounting#704

Open
angelix wants to merge 1 commit intomainfrom
ave-node-state-v2
Open

feat(gl-sdk): NodeState snapshot with UTXO-safe balance accounting#704
angelix wants to merge 1 commit intomainfrom
ave-node-state-v2

Conversation

@angelix
Copy link
Copy Markdown
Contributor

@angelix angelix commented Apr 24, 2026

Unified node_state() snapshot aggregating get_info, list_peer_channels, and list_funds into a single NodeState record with balances, capacity, utxos, and connected-channel-peer set.

Balance accounting fixes vs. a naive implementation:

  • Reserved UTXOs (in-flight PSBTs) are excluded from on-chain balance. FundOutput now carries a reserved field sourced from CLN's listfunds.outputs[].reserved; node_state() skips reserved outputs.
  • Closing-channel double-count fixed. Onchain-state channels only contribute to pending_onchain_balance_msat when we initiated the close and our payout is still timelocked (DELAYED_OUTPUT_TO_US), otherwise the payout is already visible as a wallet UTXO and would be counted twice. PeerChannel now exposes closer (ChannelSide) and status to support this gate.
  • ChannelState::from_i32 unknown fallback now maps to a new ChannelState::Unknown variant rather than Onchain, which silently counted unmapped states as closing. Unknown is treated as neither open nor closing by balance math.
  • Immature outputs (confirmed but timelocked, e.g. coinbase maturation) now surface as their own immature_onchain_balance_msat instead of being silently dropped. OutputStatus match is exhaustive — future variants become a compile error rather than silent zero.
  • The three RPCs run concurrently via tokio::join! rather than serially, cutting node_state() latency ~3x on remote nodes.

Mobile-developer API surface:

  • id, color, channel_id, peer_id, funding_txid, txid fields use lowercase hex Strings instead of raw Vec, eliminating the encode-for-display step at every call site.
  • New aggregate fields: total_onchain_msat (confirmed + unconfirmed + immature), total_balance_msat (everything the user owns), spendable_balance_msat (send-screen gate). All msat for consistency.
  • max_chan_reserve_msat (protocol reserve locked across channels).
  • utxos: Vec for coin-control UIs. Excludes spent.
  • Docstrings on channels_balance_msat / max_payable_msat explicitly name their roles: home-screen display vs send-button gate.
  • Docstrings on num_active/pending/inactive_channels clarify which balance fields each count contributes to.
  • connected_peers renamed to connected_channel_peers to reflect that it only lists peers we have a channel with; dedup via HashSet.
  • fees_collected_msat removed from NodeState (still available on GetInfoResponse); it is a routing-node concern that doesn't belong in the wallet balance snapshot.

Adds ChannelState::is_open and channel_payout_still_pending helpers, NAPI mirror types synced, and refreshes Python + Kotlin NodeState tests to match the new type shape.

@cdecker
Copy link
Copy Markdown
Collaborator

cdecker commented Apr 24, 2026

The new channel state Unknown is not covered in a number of match statements, otherwise this is good to go 👍

@angelix angelix force-pushed the ave-node-state-v2 branch 10 times, most recently from 8dbd635 to 9eb4939 Compare April 25, 2026 17:46
NodeState
- Unified node_state() snapshot aggregating get_info,
  list_peer_channels, and list_funds into a single record with
  balances, capacity, utxos, and connected-channel-peer set.
- Reserved UTXOs (in-flight PSBTs) excluded from on-chain balance.
  FundOutput now carries `reserved` from listfunds.outputs[].reserved.
- Closing-channel double-count fixed. Onchain-state channels only
  contribute to pending_onchain_balance_msat when we initiated the
  close and our payout is still timelocked (DELAYED_OUTPUT_TO_US);
  otherwise the payout is already a wallet UTXO and would be counted
  twice. PeerChannel exposes `closer` (ChannelSide) and `status` to
  support this gate.
- ChannelState::from_i32 unknown fallback now maps to a new
  ChannelState::Unknown variant rather than Onchain (which silently
  counted unmapped states as closing). Unknown is treated as neither
  open nor closing by balance math. gl-sdk-cli output formatter
  handles the new variant too.
- Immature outputs (confirmed but timelocked, e.g. coinbase
  maturation) surface as immature_onchain_balance_msat instead of
  being silently dropped. OutputStatus match is exhaustive.
- The three underlying RPCs run concurrently via tokio::join!.

NodeState aggregate fields:
- total_onchain_msat (confirmed + unconfirmed + immature)
- total_balance_msat (everything the user owns)
- spendable_balance_msat (send-screen gate)
- max_chan_reserve_msat (protocol reserve locked across channels)
- utxos: Vec<FundOutput> for coin-control UIs

Other NodeState changes:
- channels_balance_msat / max_payable_msat docstrings explicitly
  name their roles: home-screen display vs send-button gate.
- num_active/pending/inactive_channels docstrings clarify which
  balance fields each count contributes to.
- connected_peers renamed to connected_channel_peers (only lists
  peers we have a channel with); dedup via HashSet.
- fees_collected_msat removed from NodeState (still on
  GetInfoResponse); it's a routing-node concern.

Hex identifier surface
- Every identifier (pubkey, payment hash, txid, preimage, funding
  txid, channel id) on public structs is now a lowercase hex String
  instead of Vec<u8>. Affected: NodeState, PeerChannel, FundOutput,
  FundChannel, GetInfoResponse, Peer, Invoice, Pay, Payment,
  SendResponse, OnchainSendResponse (txid only; raw tx stays bytes),
  InvoicePaidEvent, ParsedInvoice.
- Kept as Vec<u8>: Credentials::load/save bytes, raw on-chain tx
  bytes, Peer.features bitfield, DeveloperCert/Signer constructor
  arguments.

list_payments fixes
- Unpaid (open) and expired invoices are dropped from list_payments.
  Only Paid invoices appear as Payment rows on the received side;
  list_invoices() still surfaces the full invoice list for callers
  that want to inspect open invoices directly.
- Payment.destination is documented as always-None for
  PaymentType::Received: Lightning's privacy model does not reveal
  the sender's pubkey to the recipient, and the only pubkey
  derivable from a paid invoice is the payee (our own node), which
  is uninteresting per-row. PaymentStatus::Pending stays on the
  public type — it remains valid for in-flight sent payments.

NAPI mirrors synced (Buffer→String everywhere the underlying field
became hex), gl-sdk-cli output formatters simplified, NodeExtensions
defaults adjusted, and Python + Kotlin tests updated to match.
@angelix angelix force-pushed the ave-node-state-v2 branch from 9eb4939 to d882300 Compare April 25, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants