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:
- the early return is skipped (
change_amount(entering change) < needed);
- the split-excess branch proceeds — trivially, since
needed is tiny;
- 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.
Crash
Recovered verbatim from core memory (fully stripped binary; build-id
ec41ba71…, Dockerelementsproject/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 reducesneededto the shortfall:emergency_satminus the unselected wallet. When the unselected wallet sits withindust_limit(546 sat) below the reserve,needed ∈ (0, 546)and:change_amount(entering change) < needed);neededis tiny;fee + needed) is itself below dust, sochange_amount()dust-caps it to 0, andassert(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 forc0 > 0) remains a latent unsoundness of the same assert — correct math, but unreachable throughfundpsbt/utxopsbttoday 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):Backtrace resolves to
change_for_emergency ← json_utxopsbt; the abort message matches the production cores verbatim. Withexcess_as_change=truethe 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 typedFUND_CANNOT_AFFORD_WITH_EMERGENCYinstead of an abort. (An alternative of merely relaxing the assert to>=is insufficient: the dust path yields 0, which still fails a>= neededcheck.)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.