ElementsProject / ElementsProject/lightning
wallet: lightningd SIGABRT in change_for_emergency when the min-emergency-msat shortfall is below the dust limit
- Dominant language
- C
- Stars
- 3.1k
- Forks
- 1k
- Avg merge
- 4d 10h
- Merged PRs (30d)
- 13
Description
> **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 (e4d3cc8b0, 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):
```console
$ lightning-cli --network=regtest utxopsbt 59500sat 253perkw 100 \
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.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.