pytest: test withhold parameter to fundchannel_complete.
What changed, and why it matters
This commit is a test-only change. It adds a new test for an existing feature called 'withhold' in the fundchannel_complete RPC, and updates the Python client library to pass the withhold parameter. There is no code change to the actual lightning node behavior, and nothing in the commit suggests a security fix or vulnerability.
No security action required. This is a normal regression/behavior test for an existing feature.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit modifies contrib/pyln-client/pyln/client/lightning.py to add a default-withhold=True parameter to the fundchannel_complete Python RPC wrapper, and adds a pytest test_zeroconf_withhold in tests/test_opening.py exercising zeroconf channel opening with the funding transaction withheld from broadcast. The test covers both withheld and non-withheld paths, mutual and unilateral close. No C/lightningd code is changed.
Changed components
contrib/pyln-client/pyln/client/lightning.pytests/test_opening.pyInspect captured patch +78 / −1
diff --git a/contrib/pyln-client/pyln/client/lightning.py b/contrib/pyln-client/pyln/client/lightning.py
index 79cae4bb..a92f07f0 100644
--- a/contrib/pyln-client/pyln/client/lightning.py
+++ b/contrib/pyln-client/pyln/client/lightning.py
@@ -847,13 +847,14 @@ class LightningRpc(UnixDomainSocketRpc):
}
return self.call("fundchannel_cancel", payload)
- def fundchannel_complete(self, node_id, psbt):
+ def fundchannel_complete(self, node_id, psbt, withhold=True):
"""
Complete channel establishment with {id}, using {psbt}.
"""
payload = {
"id": node_id,
"psbt": psbt,
+ "withhold": withhold,
}
return self.call("fundchannel_complete", payload)
diff --git a/tests/test_opening.py b/tests/test_opening.py
index 19e1a272..2261bcd1 100644
--- a/tests/test_opening.py
+++ b/tests/test_opening.py
@@ -2867,3 +2867,79 @@ def test_sendpsbt_crash(bitcoind, node_factory):
bitcoind.generate_block(1, wait_for_mempool=1)
assert l1.daemon.is_in_log('Signed and sent psbt for waiting channel')
+
+
+@pytest.mark.parametrize("stay_withheld", [True, False])
+@pytest.mark.parametrize("mutual_close", [True, False])
+def test_zeroconf_withhold(node_factory, bitcoind, stay_withheld, mutual_close):
+ plugin_path = Path(__file__).parent / "plugins" / "zeroconf-selective.py"
+
+ l1, l2 = node_factory.get_nodes(2, opts=[{'may_reconnect': True,
+ 'dev-no-reconnect': None,
+ },
+ {'plugin': str(plugin_path),
+ 'zeroconf_allow': '0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518',
+ 'may_reconnect': True,
+ 'dev-no-reconnect': None,
+ }])
+ # Try to open a mindepth=0 channel
+ l1.fundwallet(10**7)
+
+ l1.connect(l2)
+ amount = 1000000
+ funding_addr = l1.rpc.fundchannel_start(l2.info['id'], f"{amount}sat", mindepth=0)['funding_address']
+
+ # Create the funding transaction
+ psbt = l1.rpc.fundpsbt(amount, "1000perkw", 1000, excess_as_change=True)['psbt']
+ psbt = l1.rpc.addpsbtoutput(1000000, psbt, destination=funding_addr)['psbt']
+
+ # Be sure fundchannel_complete is successful
+ assert l1.rpc.fundchannel_complete(l2.info['id'], psbt, withhold=True)['commitments_secured']
+
+ # It's withheld.
+ assert only_one(l1.rpc.listpeerchannels()['channels'])['funding']['withheld'] is True
+
+ # We can use the channel (once they send an update)
+ wait_for(lambda: 'remote' in only_one(l1.rpc.listpeerchannels()['channels'])['updates'])
+ l1.rpc.xpay(l2.rpc.invoice(100, "test_zeroconf_withhold", "test_zeroconf_withhold")['bolt11'])
+
+ # But mempool is empty! No funding tx!
+ assert bitcoind.rpc.getrawmempool() == []
+
+ # Restarting doesn't make it transmit!
+ l1.restart()
+ assert bitcoind.rpc.getrawmempool() == []
+
+ if mutual_close:
+ l1.connect(l2)
+
+ if not stay_withheld:
+ # sendpsbt marks it as no longer withheld.
+ l1.rpc.sendpsbt(l1.rpc.signpsbt(psbt)['signed_psbt'])
+ assert only_one(l1.rpc.listpeerchannels()['channels'])['funding']['withheld'] is False
+ assert l1.daemon.is_in_log(r'Funding PSBT sent, and stored for rexmit \(was withheld\)')
+ wait_for(lambda: len(bitcoind.rpc.getrawmempool()) == 1)
+
+ if mutual_close:
+ ret = l1.rpc.close(l2.info['id'])
+ else:
+ ret = l1.rpc.close(l2.info['id'], unilateraltimeout=1)
+
+ if stay_withheld:
+ assert ret['txs'] == []
+ assert ret['txids'] == []
+ assert bitcoind.rpc.getrawmempool() == []
+ else:
+ assert len(ret['txs']) == 1
+ assert len(ret['txids']) == 1
+ wait_for(lambda: len(bitcoind.rpc.getrawmempool()) == 2)
+
+ # If withheld, it's moved to closed immediately.
+ if stay_withheld:
+ assert l1.rpc.listpeerchannels()['channels'] == []
+ assert only_one(l1.rpc.listclosedchannels()['closedchannels'])['funding_withheld'] is True
+ else:
+ if mutual_close:
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'CLOSINGD_COMPLETE')
+ else:
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels()['channels'])['state'] == 'AWAITING_UNILATERAL')
Why this scored 15/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.