Skip to content

wallet: lightningd SIGABRT in change_for_emergency when the min-emergency-msat shortfall is below the dust limit #9452

Description

@Amperstrand

2026-08-30 correction (by reporter): the original report mis-identified the reachable trigger as excess_as_change=true. Deterministic repro on master shows that path cannot reach the assert — when excess_as_change is set, the handler zeroes excess before calling change_for_emergency, so the split-excess branch fails on 0 − fee and the caller gets the typed FUND_CANNOT_AFFORD_WITH_EMERGENCY (313). It is the safe path. The real, reproduced trigger is a plain call whose min-emergency-msat shortfall is below the dust limit. Body below is the corrected and verified analysis.

Crash

wallet/reservation.c: change_for_emergency: Assertion `amount_sat_eq(change_amount(*change, feerate_per_kw, weight), needed)' failed.
lightningd: FATAL SIGNAL 6 (SIGABRT)

Recovered verbatim from core memory (fully stripped binary; build-id ec41ba71…, Docker elementsproject/lightningd:v26.06). Observed as a crash loop on a production signet node: five cores in five minutes (~one per restart+retry of the calling plugin), 2026-08-29.

Mechanism (corrected — verified by deterministic repro on v26.06 and master c1551c557)

wallet_has_funds() is called by pointer and reduces needed to the shortfall: emergency_sat minus the unselected wallet. When the unselected wallet sits within dust_limit (546 sat) below the reserve, needed ∈ (0, 546) and:

  1. the early return is skipped (change_amount(entering change) < needed);
  2. the split-excess branch proceeds — trivially, since needed is tiny;
  3. the change it promises (fee + needed) is itself below dust, so change_amount() dust-caps it to 0, and assert(amount_sat_eq(0, needed)) fails → daemon aborts.

The window is a 546-sat band of wallet states, which is why it surfaced only on a production wallet under funding churn and stayed hidden since the flexible version landed (e4d3cc8, Feb 2024).

The entering-change algebra from the original report (change_amount(c0 + fee + needed) = c0 + needed, unsatisfiable for c0 > 0) remains a latent unsoundness of the same assert — correct math, but unreachable through fundpsbt/utxopsbt today because of the excess zeroing; any future caller passing nonzero change and nonzero excess would trip it.

Deterministic repro (regtest, pyln harness)

Node with min-emergency-msat=25000sat; wallet: one 60,000-sat output (selected) + one 24,900-sat output (unselected → shortfall 100 < dust 546):

$ lightning-cli --network=regtest utxopsbt 59500sat 253perkw 100 <selected-outpoint> \
    reserve=0 excess_as_change=false opening_anchor_channel=true
lightningd: wallet/reservation.c:481: change_for_emergency: Assertion `amount_sat_eq(...)' failed.
lightningd: FATAL SIGNAL 6

Backtrace resolves to change_for_emergency ← json_utxopsbt; the abort message matches the production cores verbatim. With excess_as_change=true the same call returns the typed 313 and the daemon survives.

Suggested fix

Replace the assert with the honest promise check: after the split, if change_amount(*change, feerate_per_kw, weight) < needed — dust-capped shortfall, or any entering change — return false, so callers receive the typed FUND_CANNOT_AFFORD_WITH_EMERGENCY instead of an abort. (An alternative of merely relaxing the assert to >= is insufficient: the dust path yields 0, which still fails a >= needed check.)

I have a patch with this fix, a regression test for the repro above, and a randomized walk over wallet shapes checking the daemon survives every call. I can open a PR if that's useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions