splice: test mutli-channel splice script and fees
What changed, and why it matters
This commit only adds new automated tests for an experimental feature called 'splicing,' which lets users resize Lightning payment channels while they are open. It does not change any production code, so it cannot by itself introduce a security vulnerability or fix one. The tests are even marked as expected to fail (xfail) while the feature is being developed.
No security action required. Treat as normal test-suite expansion. If reviewing for release readiness, note that the tested experimental-splicing feature is still under active development and these tests are expected to fail.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff adds ~412 lines to tests/test_splice.py. It introduces regression tests for multi-channel splice scripts and fee calculations, including helper functions make_chans(), verify_chans(), and execute_script(). All new test cases are decorated with @pytest.mark.xfail(strict=True), indicating they document desired behavior for an in-development experimental-splicing feature rather than asserting currently passing behavior. No implementation code in Core Lightning is modified.
Changed components
tests/test_splice.pyInspect captured patch +412 / −0
diff --git a/tests/test_splice.py b/tests/test_splice.py
index a26c9831..9ed7b4c4 100644
--- a/tests/test_splice.py
+++ b/tests/test_splice.py
@@ -2,6 +2,7 @@ from fixtures import * # noqa: F401,F403
from pathlib import Path
from pyln.client import Millisatoshi
import pytest
+import re
import unittest
from utils import (
bkpr_account_balance, check_coin_moves, first_channel_id,
@@ -195,3 +196,414 @@ def test_script_splice_in(node_factory, bitcoind, chainparams):
l1.wait_for_channel_onchain(l2.info['id'])
account_info = only_one([acct for acct in l1.rpc.bkpr_listbalances()['accounts'] if acct['account'] == account_id])
assert not account_info['account_closed']
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_in(node_factory, bitcoind):
+ l1, l2, l3 = node_factory.line_graph(3, fundamount=1000000, wait_for_announce=True, opts={'experimental-splicing': None})
+
+ chan_id1 = l2.get_channel_id(l1)
+ chan_id2 = l2.get_channel_id(l3)
+
+ # l2 will splice funds into the channels with l1 and l3 at the same time
+ result = l2.rpc.splice(f"wallet -> 200000+fee; 100000 -> {chan_id1}; 100000 -> {chan_id2}")
+
+ l3.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+ l2.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+ l1.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+
+ wait_for(lambda: len(list(bitcoind.rpc.getrawmempool(True).keys())) == 1)
+ assert result['txid'] in list(bitcoind.rpc.getrawmempool(True).keys())
+
+ bitcoind.generate_block(6, wait_for_mempool=1)
+
+ l3.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+ l2.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+ l1.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+
+ # l2 should now have funds on their side to pay l1
+ inv = l1.rpc.invoice(10000, '1', 'no_1')
+ l2.rpc.pay(inv['bolt11'])
+
+ # l2 spliced extra funds into chan with l3 (but l3 still has 0 on their side)
+ # Send a payment l2->l3 just to check for channel stability
+ inv = l3.rpc.invoice(10000, '2', 'no_2')
+ l2.rpc.pay(inv['bolt11'])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_out(node_factory, bitcoind):
+ l1, l2, l3 = node_factory.line_graph(3, fundamount=1000000, wait_for_announce=True, opts={'experimental-splicing': None})
+
+ # We need to get funds into l1 -> l2 channel so we can splice it out
+ inv = l2.rpc.invoice(100000000, '1', 'no_1')
+ l1.rpc.pay(inv['bolt11'])
+
+ chan_id1 = l2.get_channel_id(l1)
+ chan_id2 = l2.get_channel_id(l3)
+
+ # l2 will splice funds out of the channels with l1 and l3 at the same time
+ # By default extra funds get sent to wallet
+ result = l2.rpc.splice(f"{chan_id1} -> 100000; {chan_id2} -> 100000")
+
+ l3.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+ l2.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+ l1.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+
+ wait_for(lambda: len(list(bitcoind.rpc.getrawmempool(True).keys())) == 1)
+ assert result['txid'] in list(bitcoind.rpc.getrawmempool(True).keys())
+
+ bitcoind.generate_block(6, wait_for_mempool=1)
+
+ l3.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+ l2.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+ l1.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+
+ # no extra funds in channels but do some simple payments to test stability
+ inv = l2.rpc.invoice(10000, '2', 'no_2')
+ l1.rpc.pay(inv['bolt11'])
+
+ inv = l3.rpc.invoice(10000, '3', 'no_3')
+ l2.rpc.pay(inv['bolt11'])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_inout(node_factory, bitcoind):
+ l1, l2, l3 = node_factory.line_graph(3, fundamount=1000000, wait_for_announce=True, opts={'experimental-splicing': None})
+
+ chan_id1 = l2.get_channel_id(l1)
+ chan_id2 = l2.get_channel_id(l3)
+
+ # move sats from chan 2 into chan 1
+ # By adding 10000 from wallet, the fee will be taken from this and the
+ # extra placed back into the wallet by default
+ result = l2.rpc.splice(f"wallet -> 10000; 100000 -> {chan_id1}; {chan_id2} -> 100000")
+
+ l3.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+ l2.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+ l1.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+
+ wait_for(lambda: len(list(bitcoind.rpc.getrawmempool(True).keys())) == 1)
+ assert result['txid'] in list(bitcoind.rpc.getrawmempool(True).keys())
+
+ bitcoind.generate_block(6, wait_for_mempool=1)
+
+ l3.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+ l2.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+ l1.daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+
+ # l2 should now have funds on their side to pay l1
+ inv = l1.rpc.invoice(10000, '2', 'no_2')
+ l2.rpc.pay(inv['bolt11'])
+
+ # l2 spliced extra funds into chan with l3 (but l3 still has 0 on their side)
+ # Send a payment l2->l3 just to check for channel stability
+ inv = l3.rpc.invoice(10000, '3', 'no_3')
+ l2.rpc.pay(inv['bolt11'])
+
+
+# Makes channels going from node 1 -> 2, 2 -> 3, etc up to 'qty' number channels.
+# If balanced is True, than each channel will be balanced -- otherwise the lower
+# index channel will have funds in the channel to the higher indexed one.
+#
+# The channels for the second node are returned in chanids
+def make_chans(node_factory, qty=2, fundamount=1000000, balanced=True):
+ nodes = node_factory.line_graph(qty + 1, fundamount=fundamount, opts={'experimental-splicing': None, 'allow_bad_gossip': True})
+ chanids = []
+
+ for i in range(len(nodes) - 1):
+ nodes[i].daemon.wait_for_log(' to CHANNELD_NORMAL')
+ if balanced:
+ inv = nodes[i + 1].rpc.invoice(1000 * fundamount // 2, 'balance', 'balance')
+ nodes[i].rpc.pay(inv['bolt11'])
+
+ chanids.insert(0, nodes[1].get_channel_id(nodes[0]))
+ if qty > 1:
+ chanids.insert(0, nodes[1].get_channel_id(nodes[2]))
+
+ return [nodes, chanids]
+
+
+def verify_chans(nodes, bitcoind, txid, chanids, fee, expected_balances=None, fee_multiplier=None):
+ for node in nodes:
+ node.daemon.wait_for_log(r'CHANNELD_NORMAL to CHANNELD_AWAITING_SPLICE')
+
+ wait_for(lambda: len(list(bitcoind.rpc.getrawmempool(True).keys())) == 1)
+
+ bitcoind.generate_block(6, wait_for_mempool=1)
+
+ for chanid in chanids:
+ nodes[1].daemon.wait_for_log(r'CHANNELD_AWAITING_SPLICE to CHANNELD_NORMAL')
+
+ if not expected_balances:
+ return
+
+ # Modify expected_balances by fee_multipler and fee
+ extra_funds = 0
+ last_index = 0
+
+ if fee_multiplier:
+ for index, multiplier in enumerate(fee_multiplier):
+ amount, remainder = divmod(multiplier * fee, 1)
+ expected_balances[index] += int(amount)
+ extra_funds += remainder
+ if multiplier != 0:
+ last_index = index
+
+ # If we have extra funds, put them all in the last item
+ if extra_funds > 0:
+ expected_balances[last_index] += int(extra_funds)
+
+ channel_funds = nodes[1].rpc.listfunds()['channels']
+
+ channel_funds.sort(key=lambda info: chanids.index(info['channel_id']))
+
+ funds = [info['our_amount_msat'] // 1000 for info in channel_funds]
+
+ assert funds == expected_balances
+
+
+# * expected_balances is an array of sat values
+# * fee_multiplier is an array of ints. The coorisponding channel balance expectation is adjusted
+# by the fee * fee_multiplier. For example [0, -1] reduces the second channels expected balance
+# by the fee value
+def execute_script(node_factory, bitcoind, script, expected_balances=None, fee_multiplier=None):
+ nodes, chanids = make_chans(node_factory, script.count("{}"))
+ result = nodes[1].rpc.splice(script.format(*chanids), debug_log=True)
+
+ # Extract the fee that splice scripts thinks we're using from the logs
+ nodes[1].daemon.wait_for_log(r'calc_in_ppm_and_fee starting calculations FINALIZING PASS')
+ # It can either be "fee x" or "x fee" so we match on both
+ logline = nodes[1].daemon.wait_for_log(r'(fee [0-9]+sat)|([0-9]+sat fee)')
+ # Try both regex versions
+ reg_res = re.search(r'fee ([0-9]+)sat', logline) or re.search(r'([0-9]+)sat fee', logline)
+
+ # now we should know the fee
+ fee = reg_res.group(1)
+
+ verify_chans(nodes, bitcoind, result['txid'], chanids, int(fee), expected_balances, fee_multiplier)
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_b(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 10000; {} -> 100000; {} -> 100000",
+ [500000 - 100000, 500000 - 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_c(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 10000; 100000 -> {}; {} -> 100000",
+ [500000 + 100000, 500000 - 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_d(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 250000; 100000 -> {}; 100000 -> {}",
+ [500000 + 100000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_e(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "{} -> 100000; {} -> 100000",
+ [500000 - 100000, 500000 - 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_f(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "{} -> 200000; 100000 -> {}",
+ [500000 - 200000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_g(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "{} -> 200000; 100000 -> {}; * -> wallet",
+ [500000 - 200000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_h(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 200000+fee; 100000 -> {}; 100000 -> {}",
+ [500000 + 100000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_ii(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "100000 -> {}; {} -> 100000+fee",
+ [500000 + 100000, 500000 - 100000], [0, -1])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_j(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "100000-fee -> {}; {} -> 100000",
+ [500000 + 100000, 500000 - 100000], [-1, 0])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_k(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "{} -> 10000; 1000 -> {}",
+ [500000 - 10000, 500000 + 1000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_l(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 100000; * -> {}; * -> {}",
+ [500000 + 50000, 500000 + 50000], [-0.5, -0.5])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_m(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> *+fee; 100000 -> {}; 100000 -> {}",
+ [500000 + 100000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_n(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 100%+fee; {} -> 50%; 100000 -> {}",
+ [500000 // 2, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_oo(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> *+fee; {} -> 50%; 100000 -> {}",
+ [500000 // 2, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_p(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> *; {} -> 50%+fee; 100000 -> {}",
+ [500000 // 2, 500000 + 100000], [-1, 0])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_q(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> *; {} -> 50000+fee; 100000 -> {}",
+ [500000 - 50000, 500000 + 100000], [-1, 0])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_r(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 0+fee; {} -> 100000; 100000 -> {}",
+ [500000 - 100000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_s(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 50000; {} -> 50000+fee; 100000 -> {}",
+ [500000 - 50000, 500000 + 100000], [-1, 0])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_t(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 100%; {} -> 50000+fee; 100000 -> {}",
+ [500000 - 50000, 500000 + 100000], [-1, 0])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_u(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 100%; {} -> 50000; 100000 -> {}",
+ [500000 - 50000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_v(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 100%; {} -> 100000; 100000 -> {}",
+ [500000 - 100000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_x(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "* -> wallet; * -> {}; {} -> 100000",
+ [500000 + 50000, 500000 - 100000], [-0.5, 0])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_y(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> *; 100000 -> {}; 100000 -> {}",
+ [500000 + 100000, 500000 + 100000])
+
+
+@pytest.mark.xfail(strict=True)
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
+def test_script_two_chan_splice_z(node_factory, bitcoind):
+ execute_script(node_factory, bitcoind, "wallet -> 100000; 70% -> {}; 30% -> {}",
+ [500000 + int(100000 * 0.7), 500000 + int(100000 * 0.3)], [-0.7, -0.3])
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.