Skip to content

RFC: External regulatory channel attestation for LDK (ldk-node + ldk-server) #2

@rsafier

Description

@rsafier

For the LDK Discord: single link to share for review before we file upstream. Includes the RFC bodies, three stacked fork PRs with full diffs, and end-to-end regtest evidence.

The problem

Regulated Lightning custodians (ZBD and peers) produce hourly, cryptographically-verifiable attestations of channel state for compliance. On LND and CLN we read `channel.db` / `lightningd.sqlite3` at rest and extract:

  • the unsigned commitment tx
  • the counterparty's ECDSA signature over it
  • both funding pubkeys

An auditor verifies the operator-signed manifest, then independently verifies each channel's counterparty sig against the counterparty's on-chain-visible funding pubkey via BIP-143 sighash. No trust in the operator, LND/CLN, or the extractor itself.

On LDK today this isn't possible:

  • The KVStore format is deliberately opaque and unstable — LDK's own docs reject it as an extraction surface.
  • The `Channel` gRPC message on ldk-server carries no commit tx or counterparty sig.
  • No public method on `Node`, `ChannelManager`, or `ChannelMonitor` returns the bundle in a usable shape.

The proposal

A minimal, surgical change set across three layers. Total new public surface: one accessor on rust-lightning, one method pair on ldk-node, one unary RPC + one event on ldk-server. No existing APIs break.

Stacked fork PRs

Layer PR Net new public surface
`rust-lightning` rsafier/rust-lightning#1 `pub fn ChannelMonitor::channel_keys_id() -> [u8; 32]` — one accessor, no secret material. Enables callers to re-derive the holder signer to disambiguate holder vs counterparty funding pubkey.
`ldk-node` rsafier/ldk-node#1 `pub fn Node::export_channel_attestation(&ChannelId) -> Result` + `list_channel_attestations()` + `pub mod attestation`. ~180 LOC new module; uses only existing rust-lightning public APIs plus the one new accessor.
`ldk-server` rsafier/ldk-server#3 (stacks on three prior RFC branches) `GetChannelAttestations` unary RPC + `ChannelCommitmentUpdated` event on `SubscribeEvents` + opt-in `EventKind` filter (default excludes commitment events) + `ldk-server-cli get-channel-attestations` subcommand.

End-to-end proof

Full regtest integration harness in ZBD's compliance tooling repo: bitcoind + two `ldk-server` nodes built from the fork stack, drives six channel scenarios (initial / outbound-pay / bidirectional / htlc-inflight / htlc-settled / multi-channel), snapshots each node's `get-channel-attestations` JSON. A Go extractor parses it, signs a manifest with an operator Ed25519 key, and an independent verifier reconstructs the 2-of-2 redeem script from both funding pubkeys, computes the BIP-143 sighash, and verifies the counterparty signature.

Result on current HEAD of all four branches:

```
=== RUN TestLDKExtractorAndVerifier
--- PASS: state-01-initial
--- PASS: state-02-after-outbound-pay
--- PASS: state-03-after-bidirectional
--- PASS: state-04-htlc-inflight
--- PASS: state-05-after-htlc-settled
--- PASS: state-06-multi-channel
PASS — 6/6 with full BIP-143 counterparty-signature verification
```

What LDK gets for free

Beyond the attestation use case that motivated this stack, here's
what merging it unlocks for the broader LDK ecosystem:

1. Read-only signer introspection without custom backdoors.
The new ChannelMonitor::channel_keys_id() accessor + the existing
SignerProvider::derive_channel_signer trait method let any downstream
consumer re-derive a signer from just a monitor reference. Today that's
impossible without pub(crate) access. Useful for:

  • Testing frameworks for custom signers — exercise a signer through a
    monitor round-trip.
  • Backup/restore tools that want to verify "this monitor matches this
    signer" without trusting operator metadata.
  • Channel-migration flows (rotate identity keys) that need the
    deriving blob.
  • Analytics: tag monitors by signer provenance without side-table
    bookkeeping.

2. Safe channel-state inspection on ldk-node. Today the only way
to see the current signed commit tx is unsafe_get_latest_holder_commitment_txn,
feature-gated behind unsafe_revoked_tx_signing. Scary name, not a
documented integrator API. Node::export_channel_attestation() gives
a clean, side-effect-free, no-broadcast path for:

  • Forensics / incident response. Post-outage, compare pre-recovery
    state against counterparty claims without broadcasting.
  • Dashboards — live per-channel balance, HTLC flow, commit-fee
    reserves with cryptographic attribution (the counterparty sig
    proves "peer acknowledged this"), not just LDK's internal accounting.
  • Integration tests that assert "what the node would broadcast"
    without actually doing it.
  • Complements SCB — different problem. SCB recovers a zero-balance
    force-close after total data loss; attestation reconstructs in-flight
    balance for compliance, dispute resolution, or regulator queries
    where SCB wouldn't help.

3. Closes lightningdevkit/ldk-server#134 as a side effect.
The stack already ships ChannelPending / ChannelReady / ChannelClosed
lifecycle events on SubscribeEvents — exactly what lightningdevkit#134 asks for.
The attestation work sits on top of that; lightningdevkit#134 lands for free if this
set merges.

4. First subscription filter on SubscribeEvents. The EventKind only filter is a general improvement: today every new event kind
added to the stream hits every subscriber, so high-rate events (like
commitment updates) have a rate-footgun risk. The filter lets clients
opt into just what they want. Default stays backward-compatible.

5. Real-time breach detection. The ChannelCommitmentUpdated
event stream gives subscribers cryptographic evidence of every
commitment-state advance. If a compromised operator later broadcasts
a revoked state, an independent watchdog consuming the event stream
has the superseding (newer) commitment proof in hand. BOLT-2 doesn't
let LDK defend against its own malicious operator, but third-party
monitors can — and this API gives them the data.

6. Institutional Lightning adoption unblock. Regulated custodians
cannot adopt a stack that can't produce audit artifacts. LDK merging
this set closes that specific deployment gap. The ZBD work is one
instance; every regulated-Lightning shop has the same problem today
and currently reaches for LND or CLN because those can produce
verifiable snapshots. This makes LDK a plausible choice.

7. Free integration-test infrastructure. The
lightning-attestor
regtest harness (bitcoind + two ldk-server instances, six scenarios,
BuildKit-cached builds) is a reference fixture the LDK team could
adopt for their own integration testing — it isolates ldk-server
behavior with Docker repeatability and parses every gRPC output.

Key design choices (happy to discuss alternatives)

  • New rust-lightning surface is ONE accessor, not the full `HolderCommitmentTransaction`. The signed commit tx already carries everything the attestor needs in its witness; the only gap was "which of the two pubkeys is ours," solved with `channel_keys_id` + the existing `SignerProvider::derive_channel_signer` trait method.
  • `ldk-node` is the right home for the capability. Witness parsing + signer re-derivation is node-level plumbing, not a rust-lightning primitive. We keep rust-lightning's surface stable.
  • Event stream + pull RPC are both provided. Attestors that want the latest state on demand use `GetChannelAttestations`; those that want every commitment-update (bursty HTLC settlement) subscribe to the opt-in event. Neither precludes the other.
  • `unsafe_revoked_tx_signing` feature is enabled on ldk-node's `lightning` dep. This is what gates `ChannelMonitor::unsafe_get_latest_holder_commitment_txn`. Despite the name, the method only returns the CURRENT holder commit tx — no broadcast side effect — so it's safe to enable for this read-only use. Alternative: expose a non-unsafe-named `export_current_holder_commitment_txn` on ChannelMonitor; we'd take maintainer preference here.

What we'd like from this review

  1. Confirmation the architectural split (one rust-lightning accessor → ldk-node method → ldk-server RPC) is the right shape.
  2. Naming feedback (`ChannelAttestation`, `export_channel_attestation`, field names on the bundle).
  3. Whether to keep `unsafe_revoked_tx_signing` feature-gated or carve out a non-test-only read method.
  4. Whether `commitment_number` should be surfaced (we left it at 0; can be de-obscured from locktime+sequence using both parties' payment_basepoints).
  5. Anything else before we file PRs against `lightningdevkit`.

Once the direction looks OK, we'll open the equivalent PRs upstream.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions