Skip to content

refactor(chain): replace the ChainSource enum with a pluggable chain ability seam - #7

Open
tonible14012002 wants to merge 5 commits into
lamtuanvu:logging-reductionfrom
tonible14012002:chain-ability-seam
Open

tonible14012002 wants to merge 5 commits into
lamtuanvu:logging-reductionfrom
tonible14012002:chain-ability-seam

Conversation

@tonible14012002

Copy link
Copy Markdown

Replaces the ChainSource enum with a seam: three pluggable chain ability slots plus an explicit sync-engine axis. Behaviour-neutral — no new feature, no config change, no public API change.

Based on logging-reduction (677e116), the rev the consuming monorepo pins today, so this diff is exactly the five commits below.

Why

The abstraction boundary was drawn at the chain source, not the chain ability. One closed enum decided fee estimation, lookup, broadcast and wallet-sync strategy together, so a backend could not be swapped for one ability without swapping all four. It also forced four unreachable!() arms, because each sync strategy had to exist on the other and panic.

What

  BEFORE   enum ChainSource          AFTER   struct ChainLayer
    Esplora  { 11 fields }             fee:       Arc<dyn FeeAdapter>
    Electrum { 11 fields }             lookup:    Arc<dyn LookupAdapter>
    Bitcoind { 11 fields }             broadcast: Arc<dyn BroadcastAdapter>
    (wallet, estimator, broadcaster,   engine:    Arc<dyn SyncEngine>
     kv_store, logger, metrics         shared:    held ONCE
     duplicated into all three)
  impl 1555 lines, 20x `match self`  0 matches on backend kind

src/chain/mod.rs: 2176 → 519 lines; the layer is now ~2833 lines across 11 focused files.

commit
d08a4b1 P1.1 ChainLayer wrapper — pure indirection
1911cb6 P1.2 FEE slot
0646abc P1.3 BROADCAST slot
2dffd51 P1.4 LOOKUP slot
23b826f P1.5 sync engines split out, ChainSource deleted

Design decisions the code forced

FeeAdapter is target-shaped, not block-count-shaped. bitcoind selects Conservative vs Economical estimation per ConfirmationTarget, not per block count. A block-count-keyed contract loses that and silently changes bitcoind's fee behaviour.

Adapters own their wire timeout and error classification; the seam owns only installing the result. Electrum's client already has its own timeout, batching and completeness policy, and Esplora's "HTTP 400 is benign" rule has no analogue elsewhere. Hoisting either changes behaviour.

FeeUpdate::Skip exists for one pre-seam line. bitcoind's soft testnet failure was a bare return Ok(()) that left both the fee cache and the metrics timestamp untouched. A plain Result<HashMap, _> would have advanced the timestamp as though an update had landed.

SyncEngine is a trait, not an enum. There are three types, not two: esplora and electrum are both transaction-based but share no code. An enum keeps the three-way matches and the four unreachable!() arms this change exists to remove.

LOOKUP is a narrow query ability, not a sync engine. Its consumers are swap_query_tx, derive_tx_status and the gossip UTXO verifier. Wallet sync is a separate explicit axis, selected but deliberately not abstracted.

Background loops refresh fees through the SLOT, not through the backend — otherwise a swapped fee adapter would apply to foreground calls and silently not to background ones.

Reviewing the moved code

The relocated bodies are byte-identical. Each method re-binds its state with let Self { .. } = self; so the moved lines match the match arms they came from exactly; the diff reads as a relocation, not a rewrite. The shared transaction-based background loop is lifted once into run_tx_based_sync_loop rather than copied into both tx engines.

Behaviour

Intended to be identical. Preserved deliberately:

  • per-engine sync order (transaction-based syncs the Lightning wallet before the on-chain wallet)
  • broadcast stays lossy — failures logged and dropped, never propagated. An ordered fallback across adapters is a later change and is not here.
  • exact ldk_node::Error variants at the boundary
  • exactly one timeout owner per call path

Two intended additions, both log_info at startup:

Chain ability slots: fee=esplora lookup=esplora broadcast=esplora (sync engine: esplora-tx-sync)
Chain lookup adapter 'esplora' cannot verify BOLT-7 channel announcements; the routing graph will carry unverified channel capacities.

The second surfaces an existing, previously silent condition: as_utxo_source() returns None for esplora and electrum, so add_utxo_lookup is never called and channel announcements are accepted unverified. That was true before this PR; it just said nothing.

One accepted deviation: the four unreachable!() arms no longer exist — panics that could never fire.

Testing

  cargo check --lib             0 errors, 0 new warnings, cargo fmt clean
  cargo test --lib              34 passed
  cargo test --test integration_tests_rust
                                23 passed, 0 failed  (311s)

The integration suite runs against real bitcoind 27.2 + electrs + esplora and covers all four backends:

  channel_full_cycle                     esplora
  channel_full_cycle_electrum            electrum
  channel_full_cycle_bitcoind_rpc_sync   bitcoind
  channel_full_cycle_bitcoind_rest_sync  bitcoind
  + force_close, force_close_trusted_no_reserve, 0conf,
    legacy_staticremotekey, multi_hop_sending, onchain_send_receive,
    onchain_send_all_retains_reserve, onchain_wallet_recovery,
    start_stop_reinit, simple_bolt12_send_receive,
    test_node_announcement_propagation, unified_qr_send_receive,
    lsps2_client_service_integration, connection_restart_behavior, ...

A channel full cycle cannot complete unless Filter::register_tx/register_output still reach the tx-sync client — the funding transaction would never confirm — so this exercises the highest-risk path of the refactor on every transaction-based backend.

Also verified: the consuming app builds against this branch unchanged, confirming the public builder API is untouched.

Not covered: the swaps/cycles primitives have unit coverage only; there is no integration test for swap_query_tx in this suite.

Adds one dependency

async-trait — required for object-safe async methods on Arc<dyn FeeAdapter> and friends. Already present in the consuming monorepo's graph, so it costs no additional crates downstream.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UFffyxzPikJsaiFYUcxezB

tonible14012002 and others added 5 commits September 20, 2026 12:50
Pure indirection. Every consumer now reaches the chain through ChainLayer
instead of the ChainSource enum; ChainLayer delegates every call to the
untouched enum. No behaviour change is intended or possible yet.

Node::sync_wallets' three-arm match on the source kind moves into
ChainLayer::sync_wallets_once(). The per-engine call order is reproduced
exactly, including tx-based syncing the Lightning wallet before the
on-chain wallet. The match now sits inside the chain module, where the
engine extraction will delete it.

Also adds an empty [workspace] table so a local clone placed inside a
consuming monorepo is not captured by that repo's workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFffyxzPikJsaiFYUcxezB
The three per-source fee arms become three FeeAdapter implementations.
ChainLayer owns the shared tail: install the cache, log completion,
advance and persist the fee-rate-cache metrics timestamp.

The adapter contract is target-shaped, not block-count-shaped, because
bitcoind selects Conservative vs Economical estimation BY
ConfirmationTarget; a block-count-keyed contract would lose that and
silently change bitcoind's fee behaviour. Electrum's client already
returns a finished cache with its own timeout and completeness policy,
so adapters own their wire timeout and network policy while the seam
owns only installation.

FeeUpdate carries the two non-obvious pre-seam outcomes so they survive
verbatim: bitcoind's soft testnet failure returns Skip, leaving BOTH the
cache and the metrics timestamp untouched (previously a bare
'return Ok(())'), and bitcoind logs completion only when the cache
actually changed while esplora and electrum always log.

Background sync loops now refresh fees through the layer's slot rather
than through the chain source, so a swapped adapter also takes effect
for background updates. Adds a startup log naming the adapter in each
slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFffyxzPikJsaiFYUcxezB
The three per-source broadcast arms become BroadcastAdapter
implementations; ChainLayer owns only the queue drain loop.

The arms were not interchangeable, so the contract preserves their
differences rather than unifying them: esplora and bitcoind each own a
per-transaction timeout while electrum's client owns its own and has
none here, and esplora classifies an HTTP 400 as benign (bitcoind
usually already knows the transaction) and logs it far more quietly
than a real failure. Error classification and log level therefore
belong to the adapter, not the seam.

BroadcastAdapter::ready() reproduces electrum's pre-loop client check.
Pre-seam that returned early from the whole pass; since the queue is
drained once per second this is the same behaviour -- skip this pass,
retry on the next tick.

Broadcast stays deliberately lossy: failures are logged and dropped,
never propagated. An ordered fallback across adapters is a later
change and must not arrive with the seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFffyxzPikJsaiFYUcxezB
LOOKUP is a narrow QUERY ability, not a sync engine: the status of an
arbitrary transaction, and whether channel announcements can be verified.
The two wallet-sync architectures share no interface and stay a separate
explicit axis, extracted in the next block.

swap_query_tx and as_utxo_source move out of the enum into LookupAdapter
implementations. The fail-closed contract (E6) is now stated on the trait
rather than implied by three separate arms: an adapter that cannot answer
MUST return Unreachable, because callers arm CSV and claim deadlines off
this result.

utxo_source() turns the old '_ => None' fallthrough into an explicit
per-adapter declaration. Only bitcoind can answer gettxout, so only
bitcoind verifies announcements; esplora and electrum now say so. The
node logs this at startup, because a node whose routing graph carries
unverified channel capacities previously reported nothing at all.

latest_chain_tip becomes an Arc so the lookup adapter can read the
cached tip -- used only as the fail-soft height fallback -- without
reaching back through the enum.

Verified: fork lib compiles, 34 unit tests pass (incl. the B5
fail-closed suite), node-app-ldk-node builds against it unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFffyxzPikJsaiFYUcxezB
The enum is gone. ChainLayer now holds three ability slots plus a
SyncEngine, and nothing in the crate branches on which backend is
configured.

Done as one commit rather than the planned move-then-delete pair: the
engine structs and the enum variants hold the same fields, so keeping
both would have duplicated live state -- Mutexes, a header cache and a
cached tip -- across two owners. That is worse than a larger diff.

Against D-A, SyncEngine is a TRAIT, not an enum. D-A assumed two
engines. There are three types: esplora and electrum are both
transaction-based but share no code, and an enum keeps the three-way
matches plus the four unreachable!() arms this block exists to remove.

The moved bodies are verbatim. Each method re-binds its state with
'let Self { .. } = self;' so the relocated lines are byte-identical to
the match arms they came from, and the diff is reviewable as a pure
move. The shared tx-based background loop is lifted once into
run_tx_based_sync_loop rather than copied into both tx engines.

Verified: 0 errors, no new warnings, cargo fmt clean, 34/34 unit tests
pass, node-app-ldk-node builds against it unchanged. E1 holds (no
ChainSource type remains) and E7 holds (no unreachable!() in chain/).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFffyxzPikJsaiFYUcxezB
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.

1 participant