From ef47e27b0be9c4006696a75849a6f417f573c594 Mon Sep 17 00:00:00 2001 From: enaples Date: Thu, 22 Jan 2026 14:26:15 +0100 Subject: [PATCH 1/3] lightningd: add test for tracking routing fees as income --- tests/test_bookkeeper.py | 149 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index 0a3f99a80aa1..81edd3efb3d8 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -29,6 +29,155 @@ def check_events(node, channel_id, exp_events): assert stripped == exp_events +@unittest.skipIf(TEST_NETWORK != 'regtest', "network fees hardcoded") +def test_bookkeeping_routing_fees(node_factory, bitcoind): + """ + Test that routing fees are correctly tracked as income. + """ + # Set explicit fees on l2 so we can verify exact amounts + # fee-base: 1000 msat, fee-per-satoshi: 100 (millionths) + l2_opts = {'fee-base': 1000, 'fee-per-satoshi': 100} + + l1, l2, l3 = node_factory.line_graph( + 3, + wait_for_announce=True, + opts=[{}, l2_opts, {}] + ) + + # Get channel IDs for verification + chan_l1_l2 = first_channel_id(l1, l2) + chan_l2_l3 = first_channel_id(l2, l3) + + # Payment amount: 1,000,000 msat (1000 sat) + payment_amt = 1000000 + + # Calculate expected fee for l2: + # fee = 1000 + (1000000 * 100 / 1000000) = 1000 + 100 = 1100 msat + expected_fee = 1000 + (payment_amt * 100 // 1000000) + assert expected_fee == 1100 + + # Create invoice on l3 and pay from l1 + inv = l3.rpc.invoice(payment_amt, 'test_routing', 'test routing fee') + l1.rpc.pay(inv['bolt11']) + + # Wait for payment to complete and be recorded + wait_for(lambda: only_one(l1.rpc.listpays(inv['bolt11'])['pays'])['status'] == 'complete') + + # Wait for l2's bookkeeper to record the routed event + def check_routed_event(): + events = l2.rpc.bkpr_listaccountevents()['events'] + routed = [e for e in events if e['tag'] == 'routed'] + return len(routed) == 2 # One debit, one credit for the routing + + wait_for(check_routed_event) + + # Verify routed events in l2's account events + events = l2.rpc.bkpr_listaccountevents()['events'] + routed_events = find_tags(events, 'routed') + + # Should have 2 routed events: one on each channel + assert len(routed_events) == 2 + + # Find the outbound routed event (debit from l2-l3 channel) + # and inbound routed event (credit to l1-l2 channel) + outbound = [e for e in routed_events if e['debit_msat'] > Millisatoshi(0)] + inbound = [e for e in routed_events if e['credit_msat'] > Millisatoshi(0)] + + assert len(outbound) == 1 + assert len(inbound) == 1 + + outbound_ev = outbound[0] + inbound_ev = inbound[0] + + # Outbound: l2 sends payment_amt to l3 + assert outbound_ev['account'] == chan_l2_l3 + assert outbound_ev['debit_msat'] == Millisatoshi(payment_amt) + assert outbound_ev['fees_msat'] == Millisatoshi(expected_fee) + + # Inbound: l2 receives payment_amt + fee from l1 + assert inbound_ev['account'] == chan_l1_l2 + assert inbound_ev['credit_msat'] == Millisatoshi(payment_amt + expected_fee) + assert inbound_ev['fees_msat'] == Millisatoshi(expected_fee) + + # Verify income events show the routing fee as income + income_events = l2.rpc.bkpr_listincome()['income_events'] + routed_income = find_tags(income_events, 'routed') + + # Routed income should show the fee earned + assert len(routed_income) == 1 + assert routed_income[0]['credit_msat'] == Millisatoshi(expected_fee) + assert routed_income[0]['debit_msat'] == Millisatoshi(0) + + # Verify channelsapy metrics + apy_result = l2.rpc.bkpr_channelsapy() + channels_apy = apy_result['channels_apy'] + + # Should have entries for both channels plus 'net' rollup + assert len(channels_apy) >= 3 + + # Find the channel APY entries + chan_l1_l2_apy = only_one([c for c in channels_apy if c['account'] == chan_l1_l2]) + chan_l2_l3_apy = only_one([c for c in channels_apy if c['account'] == chan_l2_l3]) + net_apy = only_one([c for c in channels_apy if c['account'] == 'net']) + + # l1-l2 channel: payment routed IN (l2 received from l1) + assert chan_l1_l2_apy['routed_in_msat'] == Millisatoshi(payment_amt + expected_fee) + assert chan_l1_l2_apy['fees_in_msat'] == Millisatoshi(expected_fee) + + # l2-l3 channel: payment routed OUT (l2 sent to l3) + assert chan_l2_l3_apy['routed_out_msat'] == Millisatoshi(payment_amt) + assert chan_l2_l3_apy['fees_out_msat'] == Millisatoshi(expected_fee) + + # Net should aggregate the fees + assert net_apy['fees_out_msat'] == Millisatoshi(expected_fee) + assert net_apy['fees_in_msat'] == Millisatoshi(expected_fee) + + # Verify utilization is non-zero (payment was routed) + assert float(chan_l2_l3_apy['utilization_out'].rstrip('%')) > 0 + + # Route a second payment from l3 to verify accumulation + payment_amt_2 = 500000 # 500 sat + expected_fee_2 = 1000 + (payment_amt_2 * 100 // 1000000) # 1050 msat + + inv2 = l3.rpc.invoice(payment_amt_2, 'test_routing_2', 'second routing test') + l1.rpc.pay(inv2['bolt11']) + + wait_for(lambda: only_one(l1.rpc.listpays(inv2['bolt11'])['pays'])['status'] == 'complete') + + # Wait for bookkeeper to process + def check_second_routed(): + events = l2.rpc.bkpr_listaccountevents()['events'] + routed = [e for e in events if e['tag'] == 'routed'] + return len(routed) == 4 # 2 per payment + + wait_for(check_second_routed) + + # Verify accumulated routing income + income_events = l2.rpc.bkpr_listincome()['income_events'] + routed_income = find_tags(income_events, 'routed') + + total_routing_income = sum(e['credit_msat'] for e in routed_income) + assert total_routing_income == Millisatoshi(expected_fee + expected_fee_2) + + # Verify updated channelsapy + apy_result = l2.rpc.bkpr_channelsapy() + net_apy = only_one([c for c in apy_result['channels_apy'] if c['account'] == 'net']) + + # Total routed amounts should include both payments + total_routed_out = payment_amt + payment_amt_2 + total_fees = expected_fee + expected_fee_2 + + assert net_apy['fees_out_msat'] == Millisatoshi(total_fees) + + # Verify persistence across restart + l2.restart() + + income_events = l2.rpc.bkpr_listincome()['income_events'] + routed_income = find_tags(income_events, 'routed') + total_routing_income = sum(e['credit_msat'] for e in routed_income) + assert total_routing_income == Millisatoshi(expected_fee + expected_fee_2) + + @unittest.skipIf(TEST_NETWORK != 'regtest', "fixme: broadcast fails, dusty") def test_bookkeeping_closing_trimmed_htlcs(node_factory, bitcoind, executor): l1, l2 = node_factory.line_graph(2) From 0c1eea303419a59591b92de868fd5197ade537f7 Mon Sep 17 00:00:00 2001 From: enaples Date: Thu, 22 Jan 2026 16:08:15 +0100 Subject: [PATCH 2/3] tests: add test penalty transaction tracking both from the punisher and the cheater peer. --- tests/test_bookkeeper.py | 170 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index 81edd3efb3d8..5fcae97ad699 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -3,7 +3,7 @@ from pyln.client import Millisatoshi, RpcError from fixtures import TEST_NETWORK from utils import ( - sync_blockheight, wait_for, only_one, first_channel_id, TIMEOUT + sync_blockheight, wait_for, only_one, first_channel_id, first_scid,TIMEOUT ) from pathlib import Path @@ -29,6 +29,174 @@ def check_events(node, channel_id, exp_events): assert stripped == exp_events +@unittest.skipIf(TEST_NETWORK != 'regtest', "network fees hardcoded") +def test_bookkeeping_penalty(node_factory, bitcoind, executor): + """ + Test penalty transaction tracking both from the punisher and the cheater peer. + """ + coin_mvt_plugin = os.path.join(os.getcwd(), 'tests/plugins/coin_movements.py') + # l1 will be the cheater, l2 will claim penalty + # dev-disable-commit-after stops commits so we can snapshot old state + l1_opts = { + 'may_fail': True, + 'feerates': (7500, 7500, 7500, 7500), + 'dev-disable-commit-after': 1, + 'plugin': coin_mvt_plugin, + # l1 will notice it broadcast revoked state + 'broken_log': r"onchaind-chan#[0-9]*: Could not find resolution for output .*: did \*we\* cheat\?" + } + l2_opts = { + 'dev-disable-commit-after': 1, + 'plugin': coin_mvt_plugin, + } + + l1, l2 = node_factory.line_graph(2, opts=[l1_opts, l2_opts]) + + channel_id = first_channel_id(l1, l2) + scid = first_scid(l1, l2) + + # Start a payment - this will get stuck due to disabled commits + t = executor.submit(l1.pay, l2, 100000000) # 100,000 sat + + assert l1.is_local_channel_active(scid) + assert l2.is_local_channel_active(scid) + + # Wait for both sides to disable commits + l1.daemon.wait_for_log('dev-disable-commit-after: disabling') + l2.daemon.wait_for_log('dev-disable-commit-after: disabling') + + # Make sure l1 got l2's commitment to the HTLC + l1.daemon.wait_for_log('got commitsig') + + # Take snapshot of l1's current (soon-to-be-old) commitment tx + old_commitment_tx = l1.rpc.dev_sign_last_tx(l2.info['id'])['tx'] + + # Re-enable commits to let the payment complete + l1.rpc.dev_reenable_commit(l2.info['id']) + l2.rpc.dev_reenable_commit(l1.info['id']) + + # Wait for payment to complete (state advances, old tx becomes revoked) + l1.daemon.wait_for_log('peer_in WIRE_UPDATE_FULFILL_HTLC') + l1.daemon.wait_for_log('peer_out WIRE_REVOKE_AND_ACK') + l2.daemon.wait_for_log('peer_out WIRE_UPDATE_FULFILL_HTLC') + l1.daemon.wait_for_log('peer_in WIRE_REVOKE_AND_ACK') + + # Payment should complete + t.result(timeout=10) + + # Make sure both sides are settled + wait_for(lambda: all([ + only_one(n.rpc.listpeerchannels()['channels'])['htlcs'] == [] + for n in (l1, l2) + ])) + + # Record l2's wallet balance before penalty + l2_balance_before = sum( + o['amount_msat'] for o in l2.rpc.listfunds()['outputs'] + ) + + # l1 cheats by broadcasting old revoked commitment + bitcoind.rpc.sendrawtransaction(old_commitment_tx) + bitcoind.generate_block(1) + + # l2 detects the breach and goes to ONCHAIN + l2.daemon.wait_for_log(' to ONCHAIN') + + # l2 should broadcast penalty transactions to claim l1's outputs + # Wait for penalty txs to be broadcast + ((_, penalty_txid1, blocks1), (_, penalty_txid2, blocks2)) = \ + l2.wait_for_onchaind_txs( + ('OUR_PENALTY_TX', 'THEIR_REVOKED_UNILATERAL/DELAYED_CHEAT_OUTPUT_TO_THEM'), + ('OUR_PENALTY_TX', 'THEIR_REVOKED_UNILATERAL/THEIR_HTLC') + ) + + # Penalty txs should be broadcast immediately (no delay) + assert blocks1 == 0 + assert blocks2 == 0 + + # Mine penalty transactions and enough blocks for resolution + bitcoind.generate_block(100, wait_for_mempool=[penalty_txid1, penalty_txid2]) + sync_blockheight(bitcoind, [l1, l2]) + + # Wait for channel to be forgotten + wait_for(lambda: l2.rpc.listpeerchannels()['channels'] == []) + + # Now verify bookkeeper tracked the penalty correctly + + # l2's account events should show 'penalty' tags + l2_events = l2.rpc.bkpr_listaccountevents()['events'] + l2_penalty_events = find_tags(l2_events, 'penalty') + + # Should have penalty events for the outputs l2 claimed + assert len(l2_penalty_events) >= 2, f"Expected at least 2 penalty events, got {len(l2_penalty_events)}" + + # All penalty events should be credits (l2 gains funds) + for ev in l2_penalty_events: + assert ev['credit_msat'] > Millisatoshi(0), "Penalty should be a credit" + assert ev['debit_msat'] == Millisatoshi(0), "Penalty should have no debit" + assert ev['account'] == channel_id, "Penalty should be on channel account" + + # Verify 'to_wallet' events show penalty funds moved to wallet + l2_to_wallet = find_tags(l2_events, 'to_wallet') + assert len(l2_to_wallet) >= 2, "Should have to_wallet events for penalty outputs" + + # Verify channel account is resolved with zero balance + l2_balances = l2.rpc.bkpr_listbalances()['accounts'] + channel_bal = [a for a in l2_balances if a['account'] == channel_id] + if channel_bal: + # If account still exists, it should be resolved + assert channel_bal[0].get('account_resolved', False), "Channel should be resolved" + assert only_one(channel_bal[0]['balances'])['balance_msat'] == Millisatoshi(0) + + # l2's wallet should have increased by penalty amounts (minus fees) + l2_wallet_bal = only_one([ + a for a in l2_balances if a['account'] == 'wallet' + ]) + l2_wallet_balance = only_one(l2_wallet_bal['balances'])['balance_msat'] + + # Wallet balance should be greater than before (gained penalty funds) + # Note: l2 already had the payment amount, plus now gets l1's funds + assert l2_wallet_balance > l2_balance_before + + # Verify income statement shows penalty as income + l2_income = l2.rpc.bkpr_listincome()['income_events'] + + # Check that channel id balance credit from penalty are recorded + credit_from_penalty = [ + e for e in l2_income + if e['account'] == channel_id and e['tag'] == 'penalty_adj' + ] + # Should have deposits from penalty tx outputs + assert len(credit_from_penalty) >= 2 + + # l1's perspective: funds lost to external (penalty to l2) + l1_events = l1.rpc.bkpr_listaccountevents()['events'] + l1_external = [e for e in l1_events if e['account'] == 'external'] + + # l1 should see penalty outputs going to external (lost funds) + l1_penalty_to_external = [ + e for e in l1_external if e['tag'] == 'penalty' + ] + assert len(l1_penalty_to_external) >= 2, f"l1 should see funds lost to penalty. L1 events\n{l1_events}" + + # l1's penalty events should be credits to external (funds leaving l1) + for ev in l1_penalty_to_external: + assert ev['credit_msat'] > Millisatoshi(0) + + # l1's channel account should also be zero (all funds lost) + l1_balances = l1.rpc.bkpr_listbalances()['accounts'] + l1_channel_bal = [a for a in l1_balances if a['account'] == channel_id] + if l1_channel_bal: + assert only_one(l1_channel_bal[0]['balances'])['balance_msat'] == Millisatoshi(0) + + # Verify persistence after restart + l2.restart() + + l2_events_after = l2.rpc.bkpr_listaccountevents()['events'] + l2_penalty_after = find_tags(l2_events_after, 'penalty') + assert len(l2_penalty_after) == len(l2_penalty_events) + + @unittest.skipIf(TEST_NETWORK != 'regtest', "network fees hardcoded") def test_bookkeeping_routing_fees(node_factory, bitcoind): """ From 37639d4b8123e331f0c6b5772d57d2e874031467 Mon Sep 17 00:00:00 2001 From: enaples Date: Fri, 23 Jan 2026 16:20:01 +0100 Subject: [PATCH 3/3] removed accounting fees test - wrong branch --- tests/test_bookkeeper.py | 151 +-------------------------------------- 1 file changed, 1 insertion(+), 150 deletions(-) diff --git a/tests/test_bookkeeper.py b/tests/test_bookkeeper.py index 5fcae97ad699..a43e0c80feec 100644 --- a/tests/test_bookkeeper.py +++ b/tests/test_bookkeeper.py @@ -195,156 +195,7 @@ def test_bookkeeping_penalty(node_factory, bitcoind, executor): l2_events_after = l2.rpc.bkpr_listaccountevents()['events'] l2_penalty_after = find_tags(l2_events_after, 'penalty') assert len(l2_penalty_after) == len(l2_penalty_events) - - -@unittest.skipIf(TEST_NETWORK != 'regtest', "network fees hardcoded") -def test_bookkeeping_routing_fees(node_factory, bitcoind): - """ - Test that routing fees are correctly tracked as income. - """ - # Set explicit fees on l2 so we can verify exact amounts - # fee-base: 1000 msat, fee-per-satoshi: 100 (millionths) - l2_opts = {'fee-base': 1000, 'fee-per-satoshi': 100} - - l1, l2, l3 = node_factory.line_graph( - 3, - wait_for_announce=True, - opts=[{}, l2_opts, {}] - ) - - # Get channel IDs for verification - chan_l1_l2 = first_channel_id(l1, l2) - chan_l2_l3 = first_channel_id(l2, l3) - - # Payment amount: 1,000,000 msat (1000 sat) - payment_amt = 1000000 - - # Calculate expected fee for l2: - # fee = 1000 + (1000000 * 100 / 1000000) = 1000 + 100 = 1100 msat - expected_fee = 1000 + (payment_amt * 100 // 1000000) - assert expected_fee == 1100 - - # Create invoice on l3 and pay from l1 - inv = l3.rpc.invoice(payment_amt, 'test_routing', 'test routing fee') - l1.rpc.pay(inv['bolt11']) - - # Wait for payment to complete and be recorded - wait_for(lambda: only_one(l1.rpc.listpays(inv['bolt11'])['pays'])['status'] == 'complete') - - # Wait for l2's bookkeeper to record the routed event - def check_routed_event(): - events = l2.rpc.bkpr_listaccountevents()['events'] - routed = [e for e in events if e['tag'] == 'routed'] - return len(routed) == 2 # One debit, one credit for the routing - - wait_for(check_routed_event) - - # Verify routed events in l2's account events - events = l2.rpc.bkpr_listaccountevents()['events'] - routed_events = find_tags(events, 'routed') - - # Should have 2 routed events: one on each channel - assert len(routed_events) == 2 - - # Find the outbound routed event (debit from l2-l3 channel) - # and inbound routed event (credit to l1-l2 channel) - outbound = [e for e in routed_events if e['debit_msat'] > Millisatoshi(0)] - inbound = [e for e in routed_events if e['credit_msat'] > Millisatoshi(0)] - - assert len(outbound) == 1 - assert len(inbound) == 1 - - outbound_ev = outbound[0] - inbound_ev = inbound[0] - - # Outbound: l2 sends payment_amt to l3 - assert outbound_ev['account'] == chan_l2_l3 - assert outbound_ev['debit_msat'] == Millisatoshi(payment_amt) - assert outbound_ev['fees_msat'] == Millisatoshi(expected_fee) - - # Inbound: l2 receives payment_amt + fee from l1 - assert inbound_ev['account'] == chan_l1_l2 - assert inbound_ev['credit_msat'] == Millisatoshi(payment_amt + expected_fee) - assert inbound_ev['fees_msat'] == Millisatoshi(expected_fee) - - # Verify income events show the routing fee as income - income_events = l2.rpc.bkpr_listincome()['income_events'] - routed_income = find_tags(income_events, 'routed') - - # Routed income should show the fee earned - assert len(routed_income) == 1 - assert routed_income[0]['credit_msat'] == Millisatoshi(expected_fee) - assert routed_income[0]['debit_msat'] == Millisatoshi(0) - - # Verify channelsapy metrics - apy_result = l2.rpc.bkpr_channelsapy() - channels_apy = apy_result['channels_apy'] - - # Should have entries for both channels plus 'net' rollup - assert len(channels_apy) >= 3 - - # Find the channel APY entries - chan_l1_l2_apy = only_one([c for c in channels_apy if c['account'] == chan_l1_l2]) - chan_l2_l3_apy = only_one([c for c in channels_apy if c['account'] == chan_l2_l3]) - net_apy = only_one([c for c in channels_apy if c['account'] == 'net']) - - # l1-l2 channel: payment routed IN (l2 received from l1) - assert chan_l1_l2_apy['routed_in_msat'] == Millisatoshi(payment_amt + expected_fee) - assert chan_l1_l2_apy['fees_in_msat'] == Millisatoshi(expected_fee) - - # l2-l3 channel: payment routed OUT (l2 sent to l3) - assert chan_l2_l3_apy['routed_out_msat'] == Millisatoshi(payment_amt) - assert chan_l2_l3_apy['fees_out_msat'] == Millisatoshi(expected_fee) - - # Net should aggregate the fees - assert net_apy['fees_out_msat'] == Millisatoshi(expected_fee) - assert net_apy['fees_in_msat'] == Millisatoshi(expected_fee) - - # Verify utilization is non-zero (payment was routed) - assert float(chan_l2_l3_apy['utilization_out'].rstrip('%')) > 0 - - # Route a second payment from l3 to verify accumulation - payment_amt_2 = 500000 # 500 sat - expected_fee_2 = 1000 + (payment_amt_2 * 100 // 1000000) # 1050 msat - - inv2 = l3.rpc.invoice(payment_amt_2, 'test_routing_2', 'second routing test') - l1.rpc.pay(inv2['bolt11']) - - wait_for(lambda: only_one(l1.rpc.listpays(inv2['bolt11'])['pays'])['status'] == 'complete') - - # Wait for bookkeeper to process - def check_second_routed(): - events = l2.rpc.bkpr_listaccountevents()['events'] - routed = [e for e in events if e['tag'] == 'routed'] - return len(routed) == 4 # 2 per payment - - wait_for(check_second_routed) - - # Verify accumulated routing income - income_events = l2.rpc.bkpr_listincome()['income_events'] - routed_income = find_tags(income_events, 'routed') - - total_routing_income = sum(e['credit_msat'] for e in routed_income) - assert total_routing_income == Millisatoshi(expected_fee + expected_fee_2) - - # Verify updated channelsapy - apy_result = l2.rpc.bkpr_channelsapy() - net_apy = only_one([c for c in apy_result['channels_apy'] if c['account'] == 'net']) - - # Total routed amounts should include both payments - total_routed_out = payment_amt + payment_amt_2 - total_fees = expected_fee + expected_fee_2 - - assert net_apy['fees_out_msat'] == Millisatoshi(total_fees) - - # Verify persistence across restart - l2.restart() - - income_events = l2.rpc.bkpr_listincome()['income_events'] - routed_income = find_tags(income_events, 'routed') - total_routing_income = sum(e['credit_msat'] for e in routed_income) - assert total_routing_income == Millisatoshi(expected_fee + expected_fee_2) - + @unittest.skipIf(TEST_NETWORK != 'regtest', "fixme: broadcast fails, dusty") def test_bookkeeping_closing_trimmed_htlcs(node_factory, bitcoind, executor):