lightningd: watch a dual-funded channel's funding outputs before lock-in
What changed, and why it matters
This fix closes a serious bug in Core Lightning's dual-funded channel opening. Before the patch, the node only started watching for a spend of the new channel's funding output after the channel had 'locked in.' A malicious or careless peer could confirm the funding transaction and immediately spend the funding output in a close transaction before lock-in. The node would miss the spend, still lock in the channel, continue routing payments over a channel whose funding no longer existed, and fail to detect a later cheating attempt. The patch arms the spend watch as soon as the funding candidate is recorded, and correctly hands the channel to onchaind if a spend is seen before lock-in.
Treat this as a security fix and include it in the next maintenance release. Operators running dual-funded channels should upgrade promptly. Review whether any node that opened dual-funded channels while running affected versions shows channels locked in without a matching unspent funding UTXO, and consider closing or monitoring those peers closely.
Security signals we found
Missing spend watch before lock-in allowed channel to lock in despite spent funding output
Peer could broadcast commitment immediately after minimum_depth confirmation, before local node arms funding_spent watch
Channel state could advance to CHANNELD_NORMAL with no UTXO backing it
Later revoked commitment would not be detected as a cheat because the funding spend was missed
Fix arms txo watch at candidate creation and routes pre-lock-in spend to onchaind
Regression test demonstrates exploit scenario: confirm funding, spend output next block, node must enter ONCHAIN
Evidence from the diff
In the dual-funding flow, channel_watch_inflight_outs() was previously deferred until lock-in. Until then the node only watched funding tx confirmation depth. The patch arms the funding-output spend watch immediately when an inflight candidate is recorded (wallet_update_channel and wallet_commit_channel), before commitment_signed is sent, because that is the first moment the peer can broadcast a commitment. On detecting a spend while in DUALOPEND_AWAITING_LOCKIN, funding_spent() now adopts the spent candidate as the channel’s funding via update_channel_from_inflight(), then starts onchaind with the correct commitment. take_over_inflight_spend_watch() prevents a duplicate txo watch so funding_spent() is not invoked twice. At lock-in, candidate watches are dropped and replaced by the channel’s own funding_spend_watch. Regression tests test_v2_funding_spent_before_lockin and test_v2_lockin_single_spend_watch are added.
Changed components
lightningd/dual_open_control.clightningd/peer_control.clightningd/test/run-invoice-select-inchan.cwallet/test/run-wallet.ctests/test_opening.pyInspect captured patch +143 / −4
### lightningd/dual_open_control.c
@@ -1356,6 +1356,9 @@ wallet_update_channel(struct lightningd *ld,
false,
false);
wallet_inflight_add(ld->wallet, inflight);
+ /* Watch the candidate's output from the moment the peer could
+ * broadcast it: it can confirm and close on it before lock-in. */
+ channel_watch_inflight_outs(ld, inflight->channel);
return inflight;
}
@@ -1660,6 +1663,9 @@ wallet_commit_channel(struct lightningd *ld,
false,
false);
wallet_inflight_add(ld->wallet, inflight);
+ /* Watch the candidate's output from the moment the peer could
+ * broadcast it: it can confirm and close on it before lock-in. */
+ channel_watch_inflight_outs(ld, inflight->channel);
return inflight;
}
@@ -2143,10 +2149,12 @@ static void handle_channel_locked(struct subd *dualopend,
short_channel_id_blocknum(*channel->scid),
true);
- /* Empty out the inflights */
+ /* Empty out the inflights, and drop the spend watches we armed on
+ * them: the funding output is watched as the channel's own from
+ * here, and a second watch would fire funding_spent() twice. */
wallet_channel_clear_inflights(dualopend->ld->wallet, channel);
+ channel_watch_inflight_outs(dualopend->ld, channel);
- /* That freed watchers in inflights: now watch funding tx */
channel_watch_depth(dualopend->ld, short_channel_id_blocknum(*channel->scid), channel);
channel_watch_funding_out(dualopend->ld, channel);
### lightningd/peer_control.c
@@ -2625,16 +2625,75 @@ static void channel_funding_found(struct lightningd *ld,
}
}
+/* The block scanner is iterating this outpoint's watches right now, and
+ * drop_to_chain() adds a funding_spend_watch if there is none: make the
+ * candidate watch that fired the channel's own instead of adding a second
+ * one on the same outpoint, which would fire funding_spent() again. */
+static void take_over_inflight_spend_watch(struct channel *channel,
+ const struct bitcoin_outpoint *spent)
+{
+ size_t n = tal_count(channel->inflight_spend_watches);
+
+ for (size_t i = 0; i < n; i++) {
+ struct txowatch *w = channel->inflight_spend_watches[i];
+
+ if (!txowatch_eq(w, spent))
+ continue;
+ tal_free(channel->funding_spend_watch);
+ channel->funding_spend_watch = tal_steal(channel, w);
+ memmove(channel->inflight_spend_watches + i,
+ channel->inflight_spend_watches + i + 1,
+ (n - i - 1) * sizeof(*channel->inflight_spend_watches));
+ tal_resize(&channel->inflight_spend_watches, n - 1);
+ return;
+ }
+}
+
static enum watch_result funding_spent(struct channel *channel,
const struct bitcoin_tx *tx,
- size_t inputnum UNUSED,
+ size_t inputnum,
const struct block *block)
{
struct bitcoin_txid txid;
struct channel_inflight *inflight;
bitcoin_txid(tx, &txid);
+ /* A dual-funding candidate spent before lock-in: whichever candidate
+ * was spent is the one that got mined, so make it the channel's
+ * funding (as opening_depth_cb would have) before onchaind sees it.
+ * Otherwise onchaind is handed the latest attempt's commitment, which
+ * spends an outpoint that may never exist. */
+ if (channel->state == DUALOPEND_AWAITING_LOCKIN) {
+ struct bitcoin_outpoint spent;
+
+ bitcoin_tx_input_get_outpoint(tx, inputnum, &spent);
+ take_over_inflight_spend_watch(channel, &spent);
+ if (!bitcoin_outpoint_eq(&spent, &channel->funding)) {
+ list_for_each(&channel->inflights, inflight, list) {
+ struct amount_msat our_msat;
+
+ if (!bitcoin_outpoint_eq(&spent,
+ &inflight->funding->outpoint))
+ continue;
+ log_unusual(channel->log,
+ "Candidate funding %s spent before"
+ " lock-in; adopting it",
+ fmt_bitcoin_outpoint(tmpctx, &spent));
+ update_channel_from_inflight(channel->peer->ld,
+ channel, inflight,
+ false);
+ if (amount_sat_to_msat(&our_msat,
+ channel->our_funds)) {
+ channel->our_msat = our_msat;
+ channel->msat_to_us_min = our_msat;
+ channel->msat_to_us_max = our_msat;
+ }
+ break;
+ }
+ }
+ }
+
/* If we're doing a splice, we expect the funding transaction to be
* spent, so don't freak out and just keep watching in that case */
list_for_each(&channel->inflights, inflight, list) {
@@ -2686,7 +2745,7 @@ void channel_watch_inflight_outs(struct lightningd *ld, struct channel *channel)
list_for_each(&channel->inflights, inflight, list)
tal_arr_expand(&channel->inflight_spend_watches,
watch_txo(channel->inflight_spend_watches,
- ld->topology, channel,
+ ld->topology, channel,
&inflight->funding->outpoint,
funding_spent));
}
### lightningd/test/run-invoice-select-inchan.c
@@ -669,6 +669,9 @@ u8 *towire_onchaind_dev_memleak(const tal_t *ctx UNNEEDED)
/* Generated stub for towire_openingd_dev_memleak */
u8 *towire_openingd_dev_memleak(const tal_t *ctx UNNEEDED)
{ fprintf(stderr, "towire_openingd_dev_memleak called!\n"); abort(); }
+/* Generated stub for txowatch_eq */
+bool txowatch_eq(const struct txowatch *w UNNEEDED, const struct bitcoin_outpoint *out UNNEEDED)
+{ fprintf(stderr, "txowatch_eq called!\n"); abort(); }
/* Generated stub for unwatch_scriptpubkey_ */
bool unwatch_scriptpubkey_(const tal_t *ctx UNNEEDED,
struct chain_topology *topo UNNEEDED,
### tests/test_opening.py
@@ -1931,6 +1931,51 @@ def bump_psbt():
assert len(chan(l1)['inflight']) == 2
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+@pytest.mark.openchannel('v2')
+def test_v2_funding_spent_before_lockin(node_factory, bitcoind):
+ """A dual-funded funding output spent before lock-in must go to onchaind.
+
+ Before lock-in we only watched whether the funding tx confirmed and how
+ deep it was; the spend watch was armed at lock-in. With funding-confirms
+ at 3 (the mainnet default) the opener can confirm the funding and close
+ on it in the next block, and we would sail on to CHANNELD_NORMAL with no
+ funding output, never noticing the spend or, later, the cheat.
+ """
+ l1, l2 = node_factory.get_nodes(2, opts={'funding-confirms': 3,
+ 'allow_warning': True,
+ 'may_reconnect': True})
+ # l2 opens; l1 is the accepter with nothing in the channel.
+ l2.fundwallet(10**7)
+ l2.rpc.connect(l1.info['id'], 'localhost', l1.port)
+ l2.rpc.fundchannel(l1.info['id'], 10**6)
+ l1.daemon.wait_for_log(' to DUALOPEND_AWAITING_LOCKIN')
+
+ # Funding confirms once; minimum_depth is 3, so no lock-in yet.
+ bitcoind.generate_block(1, wait_for_mempool=1)
+ sync_blockheight(bitcoind, [l1, l2])
+ chan = only_one(l1.rpc.listpeerchannels()['channels'])
+ assert chan['state'] == 'DUALOPEND_AWAITING_LOCKIN'
+ funding_txid = chan['funding_txid']
+
+ # The opener drops its commitment to chain without telling us.
+ commit_tx = l2.rpc.dev_sign_last_tx(l1.info['id'])['tx']
+ assert only_one(bitcoind.rpc.decoderawtransaction(commit_tx)['vin'])['txid'] == funding_txid
+ l2.stop()
+ commit_txid = bitcoind.rpc.sendrawtransaction(commit_tx)
+ bitcoind.generate_block(1, wait_for_mempool=commit_txid)
+
+ # We must notice the spend and hand the channel to onchaind.
+ l1.daemon.wait_for_log('Funding transaction spent')
+ l1.daemon.wait_for_log('Resolved FUNDING_TRANSACTION/FUNDING_OUTPUT by THEIR_UNILATERAL')
+ assert only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'ONCHAIN'
+
+ # Reaching minimum_depth must not resurrect it.
+ bitcoind.generate_block(3)
+ sync_blockheight(bitcoind, [l1])
+ assert only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'ONCHAIN'
+
+
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
@pytest.mark.openchannel('v2')
def test_lockin_restart_rescan_race(node_factory, bitcoind, chainparams):
@@ -4117,3 +4162,24 @@ def l2_channel_row():
assert l1_after == l1_before
assert not any(o['address'] == sweep_addr
for o in l1.rpc.listfunds()['outputs'])
+
+
+@pytest.mark.openchannel('v2')
+def test_v2_lockin_single_spend_watch(node_factory, bitcoind):
+ """Lock-in must drop the candidate's spend watch, not stack a second one.
+
+ The funding output is watched from the moment a candidate is signed; at
+ lock-in the channel takes over that outpoint as its own. If the candidate
+ watch survived, every later spend would fire funding_spent() twice.
+ """
+ l1, l2 = node_factory.line_graph(2, fundchannel=True,
+ opts={'may_reconnect': True})
+
+ l1.rpc.close(l2.info['id'], unilateraltimeout=1)
+ bitcoind.generate_block(1, wait_for_mempool=1)
+ l2.daemon.wait_for_log(r'State changed from \S+ to FUNDING_SPEND_SEEN')
+ l1.daemon.wait_for_log(r'State changed from \S+ to FUNDING_SPEND_SEEN')
+
+ state_change_re = re.compile(r'State changed from \S+ to FUNDING_SPEND_SEEN')
+ for node in (l1, l2):
+ assert len([l for l in node.daemon.logs if state_change_re.search(l)]) == 1
### wallet/test/run-wallet.c
@@ -765,6 +765,9 @@ u8 *towire_onchaind_known_preimage(const tal_t *ctx UNNEEDED, const struct preim
/* Generated stub for towire_openingd_dev_memleak */
u8 *towire_openingd_dev_memleak(const tal_t *ctx UNNEEDED)
{ fprintf(stderr, "towire_openingd_dev_memleak called!\n"); abort(); }
+/* Generated stub for txowatch_eq */
+bool txowatch_eq(const struct txowatch *w UNNEEDED, const struct bitcoin_outpoint *out UNNEEDED)
+{ fprintf(stderr, "txowatch_eq called!\n"); abort(); }
/* Generated stub for unsigned_channel_update */
u8 *unsigned_channel_update(const tal_t *ctx UNNEEDED,
const struct channel *channel UNNEEDED,Why this scored 79/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.