diff --git a/doc/PLUGINS.md b/doc/PLUGINS.md index 1bc0b1711..f214080f6 100644 --- a/doc/PLUGINS.md +++ b/doc/PLUGINS.md @@ -791,6 +791,47 @@ at the time lightningd broadcasts the notification. `coin_type` is the BIP173 name for the coin which moved. +### `balance_snapshot` + +Emitted after we've caught up to the chain head on first start. Lists all +current accounts (`account_id` matches the `account_id` emitted from +`coin_movement`). Useful for checkpointing account balances. + +```json +{ + "balance_snapshots": [ + { + 'node_id': '035d2b1192dfba134e10e540875d366ebc8bc353d5aa766b80c090b39c3a5d885d', + 'blockheight': 101, + 'timestamp': 1639076327, + 'accounts': [ + { + 'account_id': 'wallet', + 'balance': '0msat', + 'coin_type': 'bcrt' + } + ] + }, + { + 'node_id': '035d2b1192dfba134e10e540875d366ebc8bc353d5aa766b80c090b39c3a5d885d', + 'blockheight': 110, + 'timestamp': 1639076343, + 'accounts': [ + { + 'account_id': 'wallet', + 'balance': '995433000msat', + 'coin_type': 'bcrt' + }, { + 'account_id': '5b65c199ee862f49758603a5a29081912c8816a7c0243d1667489d244d3d055f', + 'balance': '500000000msat', + 'coin_type': 'bcrt' + } + ] + } + ] +} +``` + ### `openchannel_peer_sigs` When opening a channel with a peer using the collaborative transaction protocol diff --git a/lightningd/chaintopology.c b/lightningd/chaintopology.c index 9dcdc0124..a998065ce 100644 --- a/lightningd/chaintopology.c +++ b/lightningd/chaintopology.c @@ -27,6 +27,8 @@ /* Mutual recursion via timer. */ static void try_extend_tip(struct chain_topology *topo); +static bool first_update_complete = false; + /* init_topo sets topo->root, start_fee_estimate clears * feerate_uninitialized (even if unsuccessful) */ static void maybe_completed_init(struct chain_topology *topo) @@ -631,8 +633,10 @@ static void updates_complete(struct chain_topology *topo) topo->prev_tip = topo->tip->blkid; /* Send out an account balance snapshot */ - /* FIXME: only issue on first updates_complete call */ - send_account_balance_snapshot(topo->ld, topo->tip->height); + if (!first_update_complete) { + send_account_balance_snapshot(topo->ld, topo->tip->height); + first_update_complete = true; + } } /* If bitcoind is synced, we're now synced. */ diff --git a/lightningd/coin_mvts.c b/lightningd/coin_mvts.c index 529360db1..a77de4366 100644 --- a/lightningd/coin_mvts.c +++ b/lightningd/coin_mvts.c @@ -100,6 +100,7 @@ void send_account_balance_snapshot(struct lightningd *ld, u32 blockheight) /* Add the 'wallet' account balance */ snap->accts = tal_arr(snap, struct account_balance *, 1); bal = tal(snap, struct account_balance); + bal->balance = AMOUNT_MSAT(0); bal->acct_id = WALLET; bal->bip173_name = chainparams->lightning_hrp; diff --git a/tests/plugins/balance_snaps.py b/tests/plugins/balance_snaps.py new file mode 100755 index 000000000..30850f3bc --- /dev/null +++ b/tests/plugins/balance_snaps.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +from pyln.client import Plugin + +import json +import os.path + + +plugin = Plugin() + + +@plugin.subscribe("balance_snapshot") +def notify_balance_snapshot(plugin, balance_snapshot, **kwargs): + # we save to disk so that we don't get borked if the node restarts + # assumes notification calls are synchronous (not thread safe) + with open('snaps.json', 'a') as f: + f.write(json.dumps(balance_snapshot) + ',') + + +@plugin.method('listsnapshots') +def return_moves(plugin): + result = [] + if os.path.exists('snaps.json'): + with open('snaps.json', 'r') as f: + jd = f.read() + result = json.loads('[' + jd[:-1] + ']') + return {'balance_snapshots': result} + + +plugin.run() diff --git a/tests/test_closing.py b/tests/test_closing.py index ea3fda115..983064008 100644 --- a/tests/test_closing.py +++ b/tests/test_closing.py @@ -7,7 +7,8 @@ from utils import ( only_one, sync_blockheight, wait_for, TIMEOUT, account_balance, first_channel_id, closing_fee, TEST_NETWORK, scriptpubkey_addr, calc_lease_fee, EXPERIMENTAL_FEATURES, - check_utxos_channel, anchor_expected, check_coin_moves + check_utxos_channel, anchor_expected, check_coin_moves, + check_balance_snaps ) import os @@ -1038,12 +1039,15 @@ def test_channel_lease_lessor_cheat(node_factory, bitcoind, chainparams): ''' Check that lessee can recover funds if lessor cheats ''' + balance_snaps = os.path.join(os.getcwd(), 'tests/plugins/balance_snaps.py') opts = [{'funder-policy': 'match', 'funder-policy-mod': 100, 'lease-fee-base-msat': '100sat', 'lease-fee-basis': 100, - 'may_reconnect': True, 'allow_warning': True}, + 'may_reconnect': True, 'allow_warning': True, + 'plugin': balance_snaps}, {'funder-policy': 'match', 'funder-policy-mod': 100, 'lease-fee-base-msat': '100sat', 'lease-fee-basis': 100, - 'may_reconnect': True, 'allow_broken_log': True}] + 'may_reconnect': True, 'allow_broken_log': True, + 'plugin': balance_snaps}] l1, l2, = node_factory.get_nodes(2, opts=opts) amount = 500000 feerate = 2000 @@ -1203,17 +1207,18 @@ def test_penalty_htlc_tx_fulfill(node_factory, bitcoind, chainparams): # We track channel balances, to verify that accounting is ok. coin_mvt_plugin = os.path.join(os.getcwd(), 'tests/plugins/coin_movements.py') + balance_snaps = os.path.join(os.getcwd(), 'tests/plugins/balance_snaps.py') l1, l2, l3, l4 = node_factory.line_graph(4, opts=[{'disconnect': ['-WIRE_UPDATE_FULFILL_HTLC'], 'may_reconnect': True, 'dev-no-reconnect': None}, - {'plugin': coin_mvt_plugin, + {'plugin': [coin_mvt_plugin, balance_snaps], 'disable-mpp': None, 'dev-no-reconnect': None, 'may_reconnect': True, 'allow_broken_log': True}, - {'plugin': coin_mvt_plugin, + {'plugin': [coin_mvt_plugin, balance_snaps], 'dev-no-reconnect': None, 'may_reconnect': True, 'allow_broken_log': True}, @@ -1331,6 +1336,17 @@ def test_penalty_htlc_tx_fulfill(node_factory, bitcoind, chainparams): tags = check_utxos_channel(l2, [channel_id], expected_2, filter_channel=channel_id) check_utxos_channel(l3, [channel_id], expected_3, tags, filter_channel=channel_id) + if not chainparams['elements']: + # Also check snapshots + expected_bals_2 = [ + {'blockheight': 101, 'accounts': [{'balance': '0msat'}]}, + {'blockheight': 108, 'accounts': [{'balance': '995433000msat'}, {'balance': '500000000msat'}, {'balance': '499994999msat'}]}, + # There's a duplicate because we stop and restart l2 twice + # (both times at block 108) + {'blockheight': 108, 'accounts': [{'balance': '995433000msat'}, {'balance': '500000000msat'}, {'balance': '499994999msat'}]}, + ] + check_balance_snaps(l2, expected_bals_2) + @pytest.mark.developer("needs DEVELOPER=1") @unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "Makes use of the sqlite3 db") diff --git a/tests/utils.py b/tests/utils.py index 528090e67..8766ee53f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -96,6 +96,16 @@ def calc_lease_fee(amt, feerate, rates): return fee +def check_balance_snaps(n, expected_bals): + snaps = n.rpc.listsnapshots()['balance_snapshots'] + for snap, exp in zip(snaps, expected_bals): + assert snap['blockheight'] == exp['blockheight'] + for acct, exp_acct in zip(snap['accounts'], exp['accounts']): + # FIXME: also check 'account_id's (these change every run) + for item in ['balance']: + assert acct[item] == exp_acct[item] + + def check_coin_moves(n, account_id, expected_moves, chainparams): moves = n.rpc.call('listcoinmoves_plugin')['coin_moves'] node_id = n.info['id']