-
Notifications
You must be signed in to change notification settings - Fork 1k
lightningd: mutual close no longer falls back to broadcasting the commitment #9516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
a8b9f8a
b88a0c5
3da571e
27e0858
c2d6f07
c7a325e
fa8b9dd
170c9f6
1375c84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -215,10 +215,12 @@ static void send_offer(struct per_peer_state *pps, | |
| peer_write(pps, take(msg)); | ||
| } | ||
|
|
||
| static void tell_master_their_offer(const struct bitcoin_signature *their_sig, | ||
| /* Returns false if master says we must not agree to this offer. */ | ||
| static bool tell_master_their_offer(const struct bitcoin_signature *their_sig, | ||
| const struct bitcoin_tx *tx, | ||
| struct bitcoin_txid *tx_id) | ||
| { | ||
| bool acceptable; | ||
| u8 *msg = towire_closingd_received_signature(NULL, their_sig, tx); | ||
| if (!wire_sync_write(REQ_FD, take(msg))) | ||
| status_failed(STATUS_FAIL_MASTER_IO, | ||
|
|
@@ -227,9 +229,11 @@ static void tell_master_their_offer(const struct bitcoin_signature *their_sig, | |
|
|
||
| /* Wait for master to ack, to make sure it's in db. */ | ||
| msg = wire_sync_read(NULL, REQ_FD); | ||
| if (!fromwire_closingd_received_signature_reply(msg, tx_id)) | ||
| if (!fromwire_closingd_received_signature_reply(msg, tx_id, | ||
| &acceptable)) | ||
| master_badmsg(WIRE_CLOSINGD_RECEIVED_SIGNATURE_REPLY, msg); | ||
| tal_free(msg); | ||
| return acceptable; | ||
| } | ||
|
|
||
| /* Returns fee they offered. */ | ||
|
|
@@ -384,7 +388,17 @@ receive_offer(struct per_peer_state *pps, | |
| /* Master sorts out what is best offer, we just tell it any above min */ | ||
| if (amount_sat_greater_eq(received_fee, min_fee_to_accept)) { | ||
| status_debug("...offer is reasonable"); | ||
| tell_master_their_offer(&their_sig, tx, closing_txid); | ||
| /* Our own closing_signed for this round has usually gone | ||
| * out by now (the opener sends first), so if their fee | ||
| * matched ours they hold both signatures and can broadcast | ||
| * the close whatever we do here. Refusing only keeps us | ||
| * from recording the close as agreed. lightningd checks | ||
| * the fee against the same bounds we negotiate within, so | ||
| * this is not expected to fire. */ | ||
| if (!tell_master_their_offer(&their_sig, tx, closing_txid)) | ||
| peer_failed_warn(pps, channel_id, | ||
| "Closing fee %s is outside our fee limits", | ||
| fmt_amount_sat(tmpctx, received_fee)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: we already sent If lightningd says no, we warn and disconnect, but the peer has our sig and can still broadcast. Test puts After the other two fixes this should not happen in practice — leftover, not blocking. Maybe just a comment here?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. Once the opener's closing_signed is out, a peer whose fee matched holds both signatures and can broadcast regardless; the reject only stops closingd from recording the close as agreed. The BOLT quote is now a comment saying exactly that: fddb967. |
||
| } | ||
|
|
||
| return received_fee; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4321,6 +4321,157 @@ def test_closing_minfee(node_factory, bitcoind): | |
| bitcoind.generate_block(1, wait_for_mempool=txid) | ||
|
|
||
|
|
||
| @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd anchors not supportd') | ||
| def test_closing_fee_rounding_at_ceiling(node_factory, bitcoind): | ||
| """A close pinned at the fee ceiling stays a mutual close. | ||
|
|
||
| With every estimate at the floor, the opener's closing fee range is | ||
| a single value. Each output is rounded down to whole satoshis, so | ||
| the msat remainders end up in the fee and the transaction pays one | ||
| satoshi more than the agreed fee. lightningd must still accept it | ||
| rather than fall back to broadcasting the commitment. | ||
| """ | ||
| l1, l2 = node_factory.line_graph(2, opts={'feerates': (253, 253, 253, 253)}) | ||
| chan = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels']) | ||
| funding = int(Millisatoshi(chan['total_msat']).to_satoshi()) | ||
|
|
||
| # Leave remainders which sum to exactly 1000msat: l1 keeps ...999msat, | ||
| # l2 gets ...001msat. Rounding both down costs one satoshi of fee. | ||
| l1.pay(l2, 100000001) | ||
| wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['htlcs'] == []) | ||
|
|
||
| fee = closing_fee(253, 2) | ||
| res = l1.rpc.close(l2.info['id']) | ||
| assert res['type'] == 'mutual' | ||
| tx = bitcoind.rpc.decoderawtransaction(only_one(res['txs'])) | ||
|
|
||
| # A closing transaction, not the commitment. | ||
| assert len(tx['vout']) == 2 | ||
| assert tx['locktime'] == 0 | ||
|
|
||
| # The agreed fee plus the rounded-off remainders. | ||
| paid = funding - sum(int(round(o['value'] * 10**8)) for o in tx['vout']) | ||
| assert paid == fee + 1 | ||
| billboard = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['status'] | ||
| assert billboard == ['CLOSINGD_SIGEXCHANGE:We agreed on a closing fee of {} satoshi for tx:{}'.format(fee, tx['txid'])] | ||
|
|
||
| bitcoind.generate_block(1, wait_for_mempool=tx['txid']) | ||
| wait_for(lambda: 'ONCHAIN:Tracking mutual close transaction' in only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['status']) | ||
| assert tx['txid'] in [o['txid'] for o in l1.rpc.listfunds()['outputs']] | ||
| assert tx['txid'] in [o['txid'] for o in l2.rpc.listfunds()['outputs']] | ||
|
|
||
|
|
||
| @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd anchors not supportd') | ||
| def test_closing_fee_trims_opener_output(node_factory, bitcoind): | ||
| """A close whose fee leaves the opener's output below dust stays mutual. | ||
|
|
||
| closingd bounds the fee it agrees to at the weight of a closing | ||
| transaction with both outputs, and drops the opener's output once the | ||
| fee leaves it below the dust limit. lightningd then sees a one-output | ||
| transaction paying the opener's whole balance as fee. It must bound | ||
| the fee closingd agreed to, at the weight closingd used, rather than | ||
| reject the close and fall back to the commitment. | ||
| """ | ||
| l1, l2 = node_factory.line_graph(2, opts={'feerates': (253, 253, 253, 253)}) | ||
| chan = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels']) | ||
| funding = int(Millisatoshi(chan['total_msat']).to_satoshi()) | ||
| dust = int(Millisatoshi(chan['dust_limit_msat']).to_satoshi()) | ||
|
|
||
| # Drain the opener to what it has to keep, in whole satoshis so no | ||
| # msat remainder reaches the fee. | ||
| spendable = int(chan['spendable_msat']) | ||
| l1.pay(l2, spendable - spendable % 1000) | ||
| wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['htlcs'] == []) | ||
| chan = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels']) | ||
| ours = int(Millisatoshi(chan['to_us_msat']).to_satoshi()) | ||
|
|
||
| # A feerate whose two-output closing fee leaves l1 about half the | ||
| # dust limit, so its output is trimmed. | ||
| feerate = (ours - dust // 2) * 1000 // closing_fee(1000, 2) | ||
| fee = closing_fee(feerate, 2) | ||
| assert ours - dust < fee <= ours | ||
|
|
||
| res = l1.rpc.close(l2.info['id'], unilateraltimeout=10, | ||
| feerange=['{}perkw'.format(feerate)] * 2) | ||
| assert res['type'] == 'mutual' | ||
| tx = bitcoind.rpc.decoderawtransaction(only_one(res['txs'])) | ||
|
|
||
| # A closing transaction with only l2's output, not the commitment. | ||
| assert len(tx['vout']) == 1 | ||
| assert tx['locktime'] == 0 | ||
|
|
||
| # l1's whole balance is fee: the agreed fee plus the trimmed rest. | ||
| paid = funding - int(round(only_one(tx['vout'])['value'] * 10**8)) | ||
| assert paid == ours | ||
| billboard = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['status'] | ||
| assert billboard == ['CLOSINGD_SIGEXCHANGE:We agreed on a closing fee of {} satoshi for tx:{}'.format(fee, tx['txid'])] | ||
|
|
||
| bitcoind.generate_block(1, wait_for_mempool=tx['txid']) | ||
| wait_for(lambda: 'ONCHAIN:Tracking mutual close transaction' in only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['status']) | ||
| assert tx['txid'] not in [o['txid'] for o in l1.rpc.listfunds()['outputs']] | ||
| assert tx['txid'] in [o['txid'] for o in l2.rpc.listfunds()['outputs']] | ||
|
|
||
|
|
||
| @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd anchors not supportd') | ||
| def test_closing_feerange_below_estimates(node_factory, bitcoind): | ||
| """A close with a feerange below the estimate floor stays a mutual close. | ||
|
|
||
| closingd negotiates within the feerange given to `close`, but | ||
| lightningd checked the agreed fee against the floor derived from its | ||
| fee estimates. With estimates above the range, the agreed fee was | ||
| rejected as too low and the commitment was broadcast instead. | ||
| """ | ||
| l1, l2 = node_factory.line_graph(2, opts={'feerates': (253, 253, 253, 253), | ||
| 'may_reconnect': True}) | ||
| l1.pay(l2, 100000000) | ||
| wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['htlcs'] == []) | ||
|
|
||
| # l1's estimate floor becomes 1000perkw, well above the range. | ||
| l1.force_feerates(2000) | ||
| l1.rpc.connect(l2.info['id'], 'localhost', l2.port) | ||
|
|
||
| fee = closing_fee(253, 2) | ||
| res = l1.rpc.close(l2.info['id'], feerange=['253perkw', '253perkw']) | ||
| assert res['type'] == 'mutual' | ||
| tx = bitcoind.rpc.decoderawtransaction(only_one(res['txs'])) | ||
|
|
||
| # A closing transaction at the agreed fee, not the commitment. | ||
| assert len(tx['vout']) == 2 | ||
| assert tx['locktime'] == 0 | ||
| billboard = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['status'] | ||
| assert 'CLOSINGD_SIGEXCHANGE:We agreed on a closing fee of {} satoshi for tx:{}'.format(fee, tx['txid']) in billboard | ||
|
|
||
| bitcoind.generate_block(1, wait_for_mempool=tx['txid']) | ||
| wait_for(lambda: 'ONCHAIN:Tracking mutual close transaction' in only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['status']) | ||
| assert tx['txid'] in [o['txid'] for o in l1.rpc.listfunds()['outputs']] | ||
| assert tx['txid'] in [o['txid'] for o in l2.rpc.listfunds()['outputs']] | ||
|
|
||
|
|
||
| def test_closing_rejected_fee_fails_negotiation(node_factory, bitcoind): | ||
| """A closing fee lightningd rejects ends the negotiation. | ||
|
|
||
| closingd only learns the txid from lightningd's reply, never the | ||
| verdict, so it agrees to a rejected offer and lightningd broadcasts | ||
| the commitment as if it were the mutual close. The known reasons for | ||
| a rejection are fixed, so --dev-reject-closing-fee forces one. | ||
| """ | ||
| l1, l2 = node_factory.line_graph(2, opts=[{}, {'dev-reject-closing-fee': None}]) | ||
| l1.pay(l2, 100000000) | ||
| wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['htlcs'] == []) | ||
|
|
||
| # l2 refuses l1's offer, so nobody completes the negotiation and l1 | ||
| # closes unilaterally when its timeout expires. | ||
| res = l1.rpc.close(l2.info['id'], unilateraltimeout=10) | ||
| assert res['type'] == 'unilateral' | ||
| assert not l2.daemon.is_in_log('We agreed on a closing fee') | ||
| l2.daemon.wait_for_log('outside our fee limits') | ||
|
|
||
| # The only transaction on the wire is that unilateral close: with no | ||
| # HTLCs at stake, nothing spends its anchor to hurry it along. | ||
| txid = only_one(res['txids']) | ||
| wait_for(lambda: bitcoind.rpc.getrawmempool() == [txid]) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this also implicitly asserts no anchor CPFP tx alongside the commitment. A one-line comment saying so would stop a future anchor change from reading as a regression here.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment added in 1375c84: with no HTLCs at stake nothing spends the anchor to hurry the commitment along. |
||
|
|
||
|
|
||
| @unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd anchors not supportd') | ||
| def test_peer_anchor_push(node_factory, bitcoind, executor, chainparams): | ||
| """Test that we use anchor on peer's commit to CPFP tx""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit, pre-existing pattern:
peer_failed_warnis NORETURN, so thereturn;after it is dead. Fine to leave.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That return is the function's normal exit, for offers below the minimum and for accepted ones; nothing after peer_failed_warn is unreachable. Left as is.