splice-script: Test for msat chan balances
What changed, and why it matters
This commit only adds two new automated tests for a feature called 'splice scripting' in Core Lightning. The tests check how the software handles very small fractional bitcoin amounts (millisatoshis) during channel splicing. There is no change to production code, no bug fix, and no security patch.
No security action required. This is a test-only commit. If reviewing for quality, note the tests are marked expected-to-fail and should be revisited when the underlying splice-script rounding behavior is implemented or fixed.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit b00ff16cd79825d226116a05845f166a477e6aa3 appends two pytest test cases, test_script_splice_msat and test_script_splice_msat_roundup, to tests/test_splice.py. Both are decorated with @pytest.mark.xfail(strict=True), meaning they are currently expected to fail. They exercise the splice RPC with a channel balance containing millisatoshi fractions and verify coin-movement bookkeeping. No implementation code is modified.
Changed components
tests/test_splice.pyInspect captured patch +211 / −0
diff --git a/tests/test_splice.py b/tests/test_splice.py
index 00a0f097..b98cd02e 100644
--- a/tests/test_splice.py
+++ b/tests/test_splice.py
@@ -196,6 +196,217 @@ def test_script_splice_in(node_factory, bitcoind, chainparams):
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_splice_msat(node_factory, bitcoind, chainparams):
+ # Test splices with msat level sats to confirm rounding
+ fundamt = 1000000
+
+ coin_mvt_plugin = Path(__file__).parent / "plugins" / "coin_movements.py"
+ l1, l2 = node_factory.line_graph(2, fundamount=fundamt, wait_for_announce=True,
+ opts={'plugin': coin_mvt_plugin})
+
+ initial_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet'))
+ initial_channel_balance = Millisatoshi(bkpr_account_balance(l1, first_channel_id(l1, l2)))
+ assert initial_channel_balance == Millisatoshi(fundamt * 1000)
+
+ # Splice in 100k sats into first channel, explicitly taking out 200k sats from wallet
+ # and letting change go automatically back to wallet (100k less onchain fees)
+ spliceamt = 100000
+ withdraw_amt = 200000
+ starting_wallet_msat = withdraw_amt * 10000
+
+ sent_msats = 1111
+ # purposely pay in msats
+ inv = l2.rpc.invoice(sent_msats, '1', 'no_1')
+ l1.rpc.pay(inv['bolt11'])
+ initial_channel_balance -= sent_msats
+
+ l1.rpc.splice(f"wallet -> {withdraw_amt}; {spliceamt} -> *:?", debug_log=True)
+ p1 = only_one(l1.rpc.listpeerchannels(peer_id=l2.info['id'])['channels'])
+ p2 = only_one(l2.rpc.listpeerchannels(l1.info['id'])['channels'])
+ assert p1['inflight'][0]['splice_amount'] == spliceamt
+ assert p1['inflight'][0]['total_funding_msat'] == (fundamt + spliceamt) * 1000
+ assert p1['inflight'][0]['our_funding_msat'] == fundamt * 1000
+ assert p2['inflight'][0]['splice_amount'] == 0
+ assert p2['inflight'][0]['total_funding_msat'] == (fundamt + spliceamt) * 1000
+ assert p2['inflight'][0]['our_funding_msat'] == 0
+ bitcoind.generate_block(6, wait_for_mempool=1)
+ l2.daemon.wait_for_log(r'lightningd, splice_locked clearing inflights')
+
+ p1 = only_one(l1.rpc.listpeerchannels(peer_id=l2.info['id'])['channels'])
+ p2 = only_one(l2.rpc.listpeerchannels(l1.info['id'])['channels'])
+ assert p1['to_us_msat'] == ((fundamt + spliceamt) * 1000) - sent_msats
+ assert p1['total_msat'] == (fundamt + spliceamt) * 1000
+ assert p2['to_us_msat'] == sent_msats
+ assert p2['total_msat'] == (fundamt + spliceamt) * 1000
+ assert 'inflight' not in p1
+ assert 'inflight' not in p2
+
+ wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 1)
+ wait_for(lambda: len(l1.rpc.listfunds()['channels']) == 1)
+
+ # At the end we'd expect the balance of channel 1 to be up by the splice amount
+ end_channel_balance = Millisatoshi(bkpr_account_balance(l1, first_channel_id(l1, l2)))
+ end_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet'))
+ assert initial_channel_balance + Millisatoshi(spliceamt * 1000) == end_channel_balance
+
+ # The fee is assumed to be the difference between the start+end balances?
+ fee_guess = initial_wallet_balance + initial_channel_balance - end_channel_balance - end_wallet_balance
+
+ # We'd expect the following coin movements
+ expected_wallet_moves = [
+ # initial deposit
+ {'type': 'chain_mvt', 'credit_msat': starting_wallet_msat, 'debit_msat': 0, 'tags': ['deposit']},
+ # channel open spend
+ {'type': 'chain_mvt', 'credit_msat': 0, 'debit_msat': starting_wallet_msat, 'tags': ['withdrawal']},
+ # channel open change
+ {'type': 'chain_mvt', 'credit_msat': initial_wallet_balance, 'debit_msat': 0, 'tags': ['deposit']},
+ # splice-in spend
+ {'type': 'chain_mvt', 'debit_msat': initial_wallet_balance, 'credit_msat': 0, 'tags': ['withdrawal']},
+ # post-splice deposit
+ {'type': 'chain_mvt', 'credit_msat': initial_wallet_balance - Millisatoshi(spliceamt * 1000) - fee_guess, 'debit_msat': 0, 'tags': ['deposit']},
+ ]
+
+ check_coin_moves(l1, 'wallet', expected_wallet_moves, chainparams)
+ expected_channel_moves = [
+ # channel_open [utxo created], chain_mvt
+ {'type': 'chain_mvt', 'credit_msat': fundamt * 1000, 'debit_msat': 0, 'tags': ['channel_open', 'opener']},
+ # channel payment
+ {'type': 'channel_mvt', 'debit_msat': sent_msats, 'credit_msat': 0, 'tags': ['invoice'], 'fees_msat': 0},
+ # channel_close [utxo spend], chain_mvt (fundamt)
+ {'type': 'chain_mvt', 'debit_msat': fundamt * 1000 - sent_msats, 'credit_msat': 0, 'tags': ['channel_close', 'splice']},
+ # channel_open [utxo created], chain_mvt (fundamt - spliceamt)
+ {'type': 'chain_mvt', 'credit_msat': (fundamt + spliceamt) * 1000 - sent_msats, 'debit_msat': 0, 'tags': ['channel_open', 'opener']},
+ ]
+ check_coin_moves(l1, first_channel_id(l1, l2), expected_channel_moves, chainparams)
+
+ # Make sure the channel isn't marked as closed in bookkeeper
+ account_id = first_channel_id(l1, l2)
+ account_info = only_one([acct for acct in l1.rpc.bkpr_listbalances()['accounts'] if acct['account'] == account_id])
+ assert not account_info['account_closed']
+
+ # We'd also expect the wallet to be down by splice amt + fees
+ onchain_fees = [fee for fee in l1.rpc.bkpr_listincome()['income_events'] if fee['tag'] == 'onchain_fee']
+ assert len(onchain_fees) == 2
+ total_fees = sum([x['debit_msat'] for x in onchain_fees])
+ assert starting_wallet_msat - sent_msats == end_wallet_balance + total_fees + end_channel_balance
+
+ # Now close the channel and check that everything resolves as expected
+ l1.rpc.close(l2.info['id'])
+ 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_splice_msat_roundup(node_factory, bitcoind, chainparams):
+ # Test splices with msat level sats to confirm rounding, using an amount
+ # That would naturally round up (even though we always round down).
+ fundamt = 1000000
+
+ coin_mvt_plugin = Path(__file__).parent / "plugins" / "coin_movements.py"
+ l1, l2 = node_factory.line_graph(2, fundamount=fundamt, wait_for_announce=True,
+ opts={'plugin': coin_mvt_plugin})
+
+ initial_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet'))
+ initial_channel_balance = Millisatoshi(bkpr_account_balance(l1, first_channel_id(l1, l2)))
+ assert initial_channel_balance == Millisatoshi(fundamt * 1000)
+
+ # Splice in 100k sats into first channel, explicitly taking out 200k sats from wallet
+ # and letting change go automatically back to wallet (100k less onchain fees)
+ spliceamt = 100000
+ withdraw_amt = 200000
+ starting_wallet_msat = withdraw_amt * 10000
+
+ sent_msats = 1999
+ # purposely pay in msats
+ inv = l2.rpc.invoice(sent_msats, '1', 'no_1')
+ l1.rpc.pay(inv['bolt11'])
+ initial_channel_balance -= sent_msats
+
+ l1.rpc.splice(f"wallet -> {withdraw_amt}; {spliceamt} -> *:?", debug_log=True)
+ p1 = only_one(l1.rpc.listpeerchannels(peer_id=l2.info['id'])['channels'])
+ p2 = only_one(l2.rpc.listpeerchannels(l1.info['id'])['channels'])
+ assert p1['inflight'][0]['splice_amount'] == spliceamt
+ assert p1['inflight'][0]['total_funding_msat'] == (fundamt + spliceamt) * 1000
+ assert p1['inflight'][0]['our_funding_msat'] == fundamt * 1000
+ assert p2['inflight'][0]['splice_amount'] == 0
+ assert p2['inflight'][0]['total_funding_msat'] == (fundamt + spliceamt) * 1000
+ assert p2['inflight'][0]['our_funding_msat'] == 0
+ bitcoind.generate_block(6, wait_for_mempool=1)
+ l2.daemon.wait_for_log(r'lightningd, splice_locked clearing inflights')
+
+ p1 = only_one(l1.rpc.listpeerchannels(peer_id=l2.info['id'])['channels'])
+ p2 = only_one(l2.rpc.listpeerchannels(l1.info['id'])['channels'])
+ assert p1['to_us_msat'] == ((fundamt + spliceamt) * 1000) - sent_msats
+ assert p1['total_msat'] == (fundamt + spliceamt) * 1000
+ assert p2['to_us_msat'] == sent_msats
+ assert p2['total_msat'] == (fundamt + spliceamt) * 1000
+ assert 'inflight' not in p1
+ assert 'inflight' not in p2
+
+ wait_for(lambda: len(l1.rpc.listfunds()['outputs']) == 1)
+ wait_for(lambda: len(l1.rpc.listfunds()['channels']) == 1)
+
+ # At the end we'd expect the balance of channel 1 to be up by the splice amount
+ end_channel_balance = Millisatoshi(bkpr_account_balance(l1, first_channel_id(l1, l2)))
+ end_wallet_balance = Millisatoshi(bkpr_account_balance(l1, 'wallet'))
+ assert initial_channel_balance + Millisatoshi(spliceamt * 1000) == end_channel_balance
+
+ # The fee is assumed to be the difference between the start+end balances?
+ fee_guess = initial_wallet_balance + initial_channel_balance - end_channel_balance - end_wallet_balance
+
+ # We'd expect the following coin movements
+ expected_wallet_moves = [
+ # initial deposit
+ {'type': 'chain_mvt', 'credit_msat': starting_wallet_msat, 'debit_msat': 0, 'tags': ['deposit']},
+ # channel open spend
+ {'type': 'chain_mvt', 'credit_msat': 0, 'debit_msat': starting_wallet_msat, 'tags': ['withdrawal']},
+ # channel open change
+ {'type': 'chain_mvt', 'credit_msat': initial_wallet_balance, 'debit_msat': 0, 'tags': ['deposit']},
+ # splice-in spend
+ {'type': 'chain_mvt', 'debit_msat': initial_wallet_balance, 'credit_msat': 0, 'tags': ['withdrawal']},
+ # post-splice deposit
+ {'type': 'chain_mvt', 'credit_msat': initial_wallet_balance - Millisatoshi(spliceamt * 1000) - fee_guess, 'debit_msat': 0, 'tags': ['deposit']},
+ ]
+
+ check_coin_moves(l1, 'wallet', expected_wallet_moves, chainparams)
+ expected_channel_moves = [
+ # channel_open [utxo created], chain_mvt
+ {'type': 'chain_mvt', 'credit_msat': fundamt * 1000, 'debit_msat': 0, 'tags': ['channel_open', 'opener']},
+ # channel payment
+ {'type': 'channel_mvt', 'debit_msat': sent_msats, 'credit_msat': 0, 'tags': ['invoice'], 'fees_msat': 0},
+ # channel_close [utxo spend], chain_mvt (fundamt)
+ {'type': 'chain_mvt', 'debit_msat': fundamt * 1000 - sent_msats, 'credit_msat': 0, 'tags': ['channel_close', 'splice']},
+ # channel_open [utxo created], chain_mvt (fundamt - spliceamt)
+ {'type': 'chain_mvt', 'credit_msat': (fundamt + spliceamt) * 1000 - sent_msats, 'debit_msat': 0, 'tags': ['channel_open', 'opener']},
+ ]
+ check_coin_moves(l1, first_channel_id(l1, l2), expected_channel_moves, chainparams)
+
+ # Make sure the channel isn't marked as closed in bookkeeper
+ account_id = first_channel_id(l1, l2)
+ account_info = only_one([acct for acct in l1.rpc.bkpr_listbalances()['accounts'] if acct['account'] == account_id])
+ assert not account_info['account_closed']
+
+ # We'd also expect the wallet to be down by splice amt + fees
+ onchain_fees = [fee for fee in l1.rpc.bkpr_listincome()['income_events'] if fee['tag'] == 'onchain_fee']
+ assert len(onchain_fees) == 2
+ total_fees = sum([x['debit_msat'] for x in onchain_fees])
+ assert starting_wallet_msat - sent_msats == end_wallet_balance + total_fees + end_channel_balance
+
+ # Now close the channel and check that everything resolves as expected
+ l1.rpc.close(l2.info['id'])
+ 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.openchannel('v1')
@pytest.mark.openchannel('v2')
@unittest.skipIf(TEST_NETWORK != 'regtest', 'elementsd doesnt yet support PSBT features we need')
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.