lightningd: add listchainmoves and listchannelmoves commands.
What changed, and why it matters
This commit adds two new read-only JSON-RPC commands, listchainmoves and listchannelmoves, that let users query an audit log of on-chain and channel coin movements stored in the node's database. It is a feature addition, not a security fix, and does not change how funds are handled or how the node protects itself.
No security action required. Operators may review access controls on the RPC interface because the new commands expose detailed historical financial data (amounts, txids, payment hashes, peer IDs).
Security signals we found
New read-only RPC commands exposing previously internal audit data
Commands categorized as 'readonly' in the JSON-RPC schema
No changes to transaction construction, signing, network protocol, or authentication
No input from the request is used in SQL beyond pagination parameters (start/limit)
No security-relevant changelog or advisory language in commit message
Evidence from the diff
The patch exposes existing wallet coin-movement tables through two new RPCs: listchainmoves (on-chain balance changes) and listchannelmoves (in-channel balance changes). It registers the commands in lightningd/coin_mvts.c, adds JSON schemas, generated documentation, and a regression test. Both commands are marked readonly, accept optional index/start/limit pagination, and return already-recorded audit data. No state-modifying logic, authentication changes, or cryptographic operations are introduced.
Changed components
lightningd/coin_mvts.cJSON-RPC interface (listchainmoves, listchannelmoves)Documentation and schema filestests/test_coinmoves.pyInspect captured patch +1097 / −0
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index a7e99dac..623265b3 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -16550,6 +16550,341 @@
}
]
},
+ "listchainmoves.json": {
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "listchainmoves",
+ "title": "Command to get the audit list of all onchain coin movements.",
+ "added": "v25.09",
+ "description": [
+ "The **listchainmoves** command returns the confirmed balance changes onchain over time."
+ ],
+ "categories": [
+ "readonly"
+ ],
+ "request": {
+ "required": [],
+ "additionalProperties": false,
+ "properties": {
+ "index": {
+ "type": "string",
+ "enum": [
+ "created"
+ ],
+ "description": [
+ "How to interpret `start` and `limit`"
+ ]
+ },
+ "start": {
+ "type": "u64",
+ "description": [
+ "If `index` is specified, `start` may be specified to start from that value, which is generally returned from lightning-wait(7)."
+ ]
+ },
+ "limit": {
+ "type": "u32",
+ "description": [
+ "If `index` is specified, `limit` can be used to specify the maximum number of entries to return."
+ ]
+ }
+ },
+ "dependentUpon": {
+ "index": [
+ "start",
+ "limit"
+ ]
+ }
+ },
+ "response": {
+ "required": [
+ "chainmoves"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "chainmoves": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "account_id",
+ "primary_tag",
+ "extra_tags",
+ "credit_msat",
+ "debit_msat",
+ "timestamp",
+ "utxo",
+ "output_msat",
+ "blockheight"
+ ],
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": [
+ "This is either the channel_id corresponding to the channel involved, or a string such as `wallet` for the internal wallet, or `external` for some other external source."
+ ]
+ },
+ "credit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount credited (one of this or debit_msat is zero)"
+ ]
+ },
+ "debit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount debited (one of this or credit_msat is zero)"
+ ]
+ },
+ "timestamp": {
+ "type": "u64",
+ "description": [
+ "Time of this event in seconds since January 1 1970 UTC"
+ ]
+ },
+ "primary_tag": {
+ "type": "string",
+ "enum": [
+ "deposit",
+ "withdrawal",
+ "penalty",
+ "channel_open",
+ "channel_close",
+ "delayed_to_us",
+ "htlc_tx",
+ "htlc_timeout",
+ "htlc_fulfill",
+ "to_wallet",
+ "anchor",
+ "to_them",
+ "penalized",
+ "stolen",
+ "to_miner"
+ ],
+ "description": [
+ "A set of one or more tags defining the nature of the change"
+ ]
+ },
+ "extra_tags": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "ignored",
+ "opener",
+ "leased",
+ "stealable",
+ "splice"
+ ]
+ },
+ "description": [
+ "A set of additional tags expanding on the details"
+ ]
+ },
+ "peer_id": {
+ "type": "pubkey",
+ "description": [
+ "The lightning peer associated with this onchain event"
+ ]
+ },
+ "originating_account": {
+ "type": "string",
+ "description": [
+ "This is either a channel_id corresponding to the source channel involved, or a string such as `wallet` for the internal wallet."
+ ]
+ },
+ "spending_txid": {
+ "type": "txid",
+ "description": [
+ "The transaction ID which did the spending."
+ ]
+ },
+ "utxo": {
+ "type": "outpoint",
+ "description": [
+ "The txid and outpoint number spent for this balance change."
+ ]
+ },
+ "payment_hash": {
+ "type": "hash",
+ "description": [
+ "The payment hash associated with this balance change."
+ ]
+ },
+ "output_msat": {
+ "type": "msat",
+ "description": [
+ "The output amount (always a whole number of sats). Note that in some cases (e.g. channel opens), not all these belong to us."
+ ]
+ },
+ "output_count": {
+ "type": "u32",
+ "description": [
+ "The number of outputs in the `txid` (so you can tell once you've seen events for all of them)."
+ ]
+ },
+ "blockheight": {
+ "type": "u32",
+ "description": [
+ "The block number where `txid` appeared (alternately, where `utxo` was spent)."
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ "On failure, one of the following error codes may be returned:",
+ "",
+ "- -32602: Error in given parameters."
+ ],
+ "resources": [
+ "Main web site: <https://github.com/ElementsProject/lightning>"
+ ]
+ },
+ "listchannelmoves.json": {
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "listchannelmoves",
+ "title": "Command to get the audit list of all channel coin movements.",
+ "added": "v25.09",
+ "description": [
+ "The **listchannelmoves** command returns the confirmed balance changes within lightning channels over time."
+ ],
+ "categories": [
+ "readonly"
+ ],
+ "request": {
+ "required": [],
+ "additionalProperties": false,
+ "properties": {
+ "index": {
+ "type": "string",
+ "enum": [
+ "created"
+ ],
+ "description": [
+ "How to interpret `start` and `limit`"
+ ]
+ },
+ "start": {
+ "type": "u64",
+ "description": [
+ "If `index` is specified, `start` may be specified to start from that value, which is generally returned from lightning-wait(7)."
+ ]
+ },
+ "limit": {
+ "type": "u32",
+ "description": [
+ "If `index` is specified, `limit` can be used to specify the maximum number of entries to return."
+ ]
+ }
+ },
+ "dependentUpon": {
+ "index": [
+ "start",
+ "limit"
+ ]
+ }
+ },
+ "response": {
+ "required": [
+ "channelmoves"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "channelmoves": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "account_id",
+ "primary_tag",
+ "credit_msat",
+ "debit_msat",
+ "timestamp",
+ "fees_msat"
+ ],
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": [
+ "The channel_id corresponding to the channel involved."
+ ]
+ },
+ "credit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount credited (one of this or debit_msat is zero)"
+ ]
+ },
+ "debit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount debited (one of this or credit_msat is zero)"
+ ]
+ },
+ "timestamp": {
+ "type": "u64",
+ "description": [
+ "Time of this event in seconds since January 1 1970 UTC"
+ ]
+ },
+ "primary_tag": {
+ "type": "string",
+ "enum": [
+ "invoice",
+ "routed",
+ "pushed",
+ "lease_fee",
+ "channel_proposed",
+ "penalty_adj",
+ "journal"
+ ],
+ "description": [
+ "A set of one or more tags defining the nature of the change"
+ ]
+ },
+ "payment_hash": {
+ "type": "hash",
+ "description": [
+ "The hash associated with this payment (not present for leases or push funding)"
+ ]
+ },
+ "part_id": {
+ "type": "u64",
+ "description": [
+ "The part_id for the payment (the `payment_hash`, `group_id`, `part_id` tuple will be unique)"
+ ]
+ },
+ "group_id": {
+ "type": "u64",
+ "description": [
+ "The group_id for the payment (the `payment_hash`, `group_id`, `part_id` tuple will be unique)"
+ ]
+ },
+ "fees_msat": {
+ "type": "msat",
+ "description": [
+ "The fees paid for this payment"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ "On failure, one of the following error codes may be returned:",
+ "",
+ "- -32602: Error in given parameters."
+ ],
+ "resources": [
+ "Main web site: <https://github.com/ElementsProject/lightning>"
+ ]
+ },
"listchannels.json": {
"$schema": "../rpc-schema-draft.json",
"type": "object",
diff --git a/doc/Makefile b/doc/Makefile
index 3bb9d54d..7c8acd36 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -77,6 +77,8 @@ MARKDOWNPAGES := doc/addgossip.7 \
doc/invoicerequest.7 \
doc/keysend.7 \
doc/listaddresses.7 \
+ doc/listchainmoves.7 \
+ doc/listchannelmoves.7 \
doc/listchannels.7 \
doc/listclosedchannels.7 \
doc/listconfigs.7 \
diff --git a/doc/index.rst b/doc/index.rst
index 760914c2..a471fc38 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -91,6 +91,8 @@ Core Lightning Documentation
lightningd-config <lightningd-config.5.md>
lightningd-rpc <lightningd-rpc.7.md>
listaddresses <listaddresses.7.md>
+ listchainmoves <listchainmoves.7.md>
+ listchannelmoves <listchannelmoves.7.md>
listchannels <listchannels.7.md>
listclosedchannels <listclosedchannels.7.md>
listconfigs <listconfigs.7.md>
diff --git a/doc/schemas/listchainmoves.json b/doc/schemas/listchainmoves.json
new file mode 100644
index 00000000..b8ec5552
--- /dev/null
+++ b/doc/schemas/listchainmoves.json
@@ -0,0 +1,193 @@
+{
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "listchainmoves",
+ "title": "Command to get the audit list of all onchain coin movements.",
+ "added": "v25.09",
+ "description": [
+ "The **listchainmoves** command returns the confirmed balance changes onchain over time."
+ ],
+ "categories": [
+ "readonly"
+ ],
+ "request": {
+ "required": [],
+ "additionalProperties": false,
+ "properties": {
+ "index": {
+ "type": "string",
+ "enum": [
+ "created"
+ ],
+ "description": [
+ "How to interpret `start` and `limit`"
+ ]
+ },
+ "start": {
+ "type": "u64",
+ "description": [
+ "If `index` is specified, `start` may be specified to start from that value, which is generally returned from lightning-wait(7)."
+ ]
+ },
+ "limit": {
+ "type": "u32",
+ "description": [
+ "If `index` is specified, `limit` can be used to specify the maximum number of entries to return."
+ ]
+ }
+ },
+ "dependentUpon": {
+ "index": [
+ "start",
+ "limit"
+ ]
+ }
+ },
+ "response": {
+ "required": [
+ "chainmoves"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "chainmoves": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "account_id",
+ "primary_tag",
+ "extra_tags",
+ "credit_msat",
+ "debit_msat",
+ "timestamp",
+ "utxo",
+ "output_msat",
+ "blockheight"
+ ],
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": [
+ "This is either the channel_id corresponding to the channel involved, or a string such as `wallet` for the internal wallet, or `external` for some other external source."
+ ]
+ },
+ "credit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount credited (one of this or debit_msat is zero)"
+ ]
+ },
+ "debit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount debited (one of this or credit_msat is zero)"
+ ]
+ },
+ "timestamp": {
+ "type": "u64",
+ "description": [
+ "Time of this event in seconds since January 1 1970 UTC"
+ ]
+ },
+ "primary_tag": {
+ "type": "string",
+ "enum": [
+ "deposit",
+ "withdrawal",
+ "penalty",
+ "channel_open",
+ "channel_close",
+ "delayed_to_us",
+ "htlc_tx",
+ "htlc_timeout",
+ "htlc_fulfill",
+ "to_wallet",
+ "anchor",
+ "to_them",
+ "penalized",
+ "stolen",
+ "to_miner"
+ ],
+ "description": [
+ "A set of one or more tags defining the nature of the change"
+ ]
+ },
+ "extra_tags": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "ignored",
+ "opener",
+ "leased",
+ "stealable",
+ "splice"
+ ]
+ },
+ "description": [
+ "A set of additional tags expanding on the details"
+ ]
+ },
+ "peer_id": {
+ "type": "pubkey",
+ "description": [
+ "The lightning peer associated with this onchain event"
+ ]
+ },
+ "originating_account": {
+ "type": "string",
+ "description": [
+ "This is either a channel_id corresponding to the source channel involved, or a string such as `wallet` for the internal wallet."
+ ]
+ },
+ "spending_txid": {
+ "type": "txid",
+ "description": [
+ "The transaction ID which did the spending."
+ ]
+ },
+ "utxo": {
+ "type": "outpoint",
+ "description": [
+ "The txid and outpoint number spent for this balance change."
+ ]
+ },
+ "payment_hash": {
+ "type": "hash",
+ "description": [
+ "The payment hash associated with this balance change."
+ ]
+ },
+ "output_msat": {
+ "type": "msat",
+ "description": [
+ "The output amount (always a whole number of sats). Note that in some cases (e.g. channel opens), not all these belong to us."
+ ]
+ },
+ "output_count": {
+ "type": "u32",
+ "description": [
+ "The number of outputs in the `txid` (so you can tell once you've seen events for all of them)."
+ ]
+ },
+ "blockheight": {
+ "type": "u32",
+ "description": [
+ "The block number where `txid` appeared (alternately, where `utxo` was spent)."
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ "On failure, one of the following error codes may be returned:",
+ "",
+ "- -32602: Error in given parameters."
+ ],
+ "resources": [
+ "Main web site: <https://github.com/ElementsProject/lightning>"
+ ]
+}
diff --git a/doc/schemas/listchannelmoves.json b/doc/schemas/listchannelmoves.json
new file mode 100644
index 00000000..fead4634
--- /dev/null
+++ b/doc/schemas/listchannelmoves.json
@@ -0,0 +1,142 @@
+{
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "listchannelmoves",
+ "title": "Command to get the audit list of all channel coin movements.",
+ "added": "v25.09",
+ "description": [
+ "The **listchannelmoves** command returns the confirmed balance changes within lightning channels over time."
+ ],
+ "categories": [
+ "readonly"
+ ],
+ "request": {
+ "required": [],
+ "additionalProperties": false,
+ "properties": {
+ "index": {
+ "type": "string",
+ "enum": [
+ "created"
+ ],
+ "description": [
+ "How to interpret `start` and `limit`"
+ ]
+ },
+ "start": {
+ "type": "u64",
+ "description": [
+ "If `index` is specified, `start` may be specified to start from that value, which is generally returned from lightning-wait(7)."
+ ]
+ },
+ "limit": {
+ "type": "u32",
+ "description": [
+ "If `index` is specified, `limit` can be used to specify the maximum number of entries to return."
+ ]
+ }
+ },
+ "dependentUpon": {
+ "index": [
+ "start",
+ "limit"
+ ]
+ }
+ },
+ "response": {
+ "required": [
+ "channelmoves"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "channelmoves": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "account_id",
+ "primary_tag",
+ "credit_msat",
+ "debit_msat",
+ "timestamp",
+ "fees_msat"
+ ],
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": [
+ "The channel_id corresponding to the channel involved."
+ ]
+ },
+ "credit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount credited (one of this or debit_msat is zero)"
+ ]
+ },
+ "debit_msat": {
+ "type": "msat",
+ "description": [
+ "Amount debited (one of this or credit_msat is zero)"
+ ]
+ },
+ "timestamp": {
+ "type": "u64",
+ "description": [
+ "Time of this event in seconds since January 1 1970 UTC"
+ ]
+ },
+ "primary_tag": {
+ "type": "string",
+ "enum": [
+ "invoice",
+ "routed",
+ "pushed",
+ "lease_fee",
+ "channel_proposed",
+ "penalty_adj",
+ "journal"
+ ],
+ "description": [
+ "A set of one or more tags defining the nature of the change"
+ ]
+ },
+ "payment_hash": {
+ "type": "hash",
+ "description": [
+ "The hash associated with this payment (not present for leases or push funding)"
+ ]
+ },
+ "part_id": {
+ "type": "u64",
+ "description": [
+ "The part_id for the payment (the `payment_hash`, `group_id`, `part_id` tuple will be unique)"
+ ]
+ },
+ "group_id": {
+ "type": "u64",
+ "description": [
+ "The group_id for the payment (the `payment_hash`, `group_id`, `part_id` tuple will be unique)"
+ ]
+ },
+ "fees_msat": {
+ "type": "msat",
+ "description": [
+ "The fees paid for this payment"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ "On failure, one of the following error codes may be returned:",
+ "",
+ "- -32602: Error in given parameters."
+ ],
+ "resources": [
+ "Main web site: <https://github.com/ElementsProject/lightning>"
+ ]
+}
diff --git a/lightningd/coin_mvts.c b/lightningd/coin_mvts.c
index e31ede11..1ef0a213 100644
--- a/lightningd/coin_mvts.c
+++ b/lightningd/coin_mvts.c
@@ -1,5 +1,6 @@
#include "config.h"
#include <ccan/array_size/array_size.h>
+#include <common/json_command.h>
#include <lightningd/channel.h>
#include <lightningd/coin_mvts.h>
#include <lightningd/notification.h>
@@ -250,3 +251,109 @@ void json_add_channel_mvt_fields(struct json_stream *stream,
}
json_add_amount_msat(stream, "fees_msat", chan_mvt->fees);
}
+
+static struct command_result *json_listchainmoves(struct command *cmd,
+ const char *buffer,
+ const jsmntok_t *obj UNNEEDED,
+ const jsmntok_t *params)
+{
+ struct json_stream *response;
+ struct db_stmt *stmt;
+ enum wait_index *listindex;
+ u64 *liststart;
+ u32 *listlimit;
+
+ if (!param(cmd, buffer, params,
+ p_opt("index", param_index, &listindex),
+ p_opt_def("start", param_u64, &liststart, 0),
+ p_opt("limit", param_u32, &listlimit),
+ NULL))
+ return command_param_failed();
+
+ if (*liststart != 0 && !listindex) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Can only specify {start} with {index}");
+ }
+ if (listlimit && !listindex) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Can only specify {limit} with {index}");
+ }
+ if (listindex && *listindex != WAIT_INDEX_CREATED) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "index must be 'created', since chainmoves are never updated");
+ }
+ response = json_stream_success(cmd);
+ json_array_start(response, "chainmoves");
+ for (stmt = wallet_chain_moves_first(cmd->ld->wallet, *liststart, listlimit);
+ stmt;
+ stmt = wallet_chain_moves_next(cmd->ld->wallet, stmt)) {
+ struct chain_coin_mvt *chain_mvt;
+ u64 id;
+ chain_mvt = wallet_chain_move_extract(cmd, stmt, cmd->ld, &id);
+ json_object_start(response, NULL);
+ json_add_chain_mvt_fields(response, false, false, false, chain_mvt);
+ json_object_end(response);
+ }
+ json_array_end(response);
+
+ return command_success(cmd, response);
+}
+static const struct json_command listchainmoves_command = {
+ "listchainmoves",
+ json_listchainmoves,
+};
+AUTODATA(json_command, &listchainmoves_command);
+
+static struct command_result *json_listchannelmoves(struct command *cmd,
+ const char *buffer,
+ const jsmntok_t *obj UNNEEDED,
+ const jsmntok_t *params)
+{
+ struct json_stream *response;
+ struct db_stmt *stmt;
+ enum wait_index *listindex;
+ u64 *liststart;
+ u32 *listlimit;
+
+ if (!param(cmd, buffer, params,
+ p_opt("index", param_index, &listindex),
+ p_opt_def("start", param_u64, &liststart, 0),
+ p_opt("limit", param_u32, &listlimit),
+ NULL))
+ return command_param_failed();
+
+ if (*liststart != 0 && !listindex) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Can only specify {start} with {index}");
+ }
+ if (listlimit && !listindex) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Can only specify {limit} with {index}");
+ }
+ if (listindex && *listindex != WAIT_INDEX_CREATED) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "index must be 'created', since channelmoves are never updated");
+ }
+
+ response = json_stream_success(cmd);
+ json_array_start(response, "channelmoves");
+ for (stmt = wallet_channel_moves_first(cmd->ld->wallet, *liststart, listlimit);
+ stmt;
+ stmt = wallet_channel_moves_next(cmd->ld->wallet, stmt)) {
+ struct channel_coin_mvt *chan_mvt;
+ u64 id;
+ chan_mvt = wallet_channel_move_extract(cmd, stmt, cmd->ld, &id);
+ json_object_start(response, NULL);
+ /* No deprecated tags[], no extra_tags field */
+ json_add_channel_mvt_fields(response, false, chan_mvt, false);
+ json_object_end(response);
+ }
+ json_array_end(response);
+
+ return command_success(cmd, response);
+}
+static const struct json_command listchannelmoves_command = {
+ "listchannelmoves",
+ json_listchannelmoves,
+};
+AUTODATA(json_command, &listchannelmoves_command);
diff --git a/tests/test_coinmoves.py b/tests/test_coinmoves.py
new file mode 100644
index 00000000..a17f5546
--- /dev/null
+++ b/tests/test_coinmoves.py
@@ -0,0 +1,315 @@
+from fixtures import * # noqa: F401,F403
+from fixtures import TEST_NETWORK
+from utils import (
+ sync_blockheight, wait_for, only_one
+)
+
+import unittest
+import pytest
+from pyln.testing.utils import EXPERIMENTAL_DUAL_FUND
+
+
+def check_moves(moves, expected):
+ for m in moves:
+ del m['timestamp']
+ assert moves == expected
+
+
+def check_channel_moves(node, expected):
+ check_moves(node.rpc.listchannelmoves()['channelmoves'], expected)
+
+
+def check_chain_moves(node, expected):
+ check_moves(node.rpc.listchainmoves()['chainmoves'], expected)
+
+
+@pytest.mark.openchannel('v1')
+@pytest.mark.openchannel('v2')
+@unittest.skipIf(TEST_NETWORK != 'regtest', "Amounts are for regtest.")
+def test_coinmoves(node_factory, bitcoind):
+ l1, l2, l3 = node_factory.get_nodes(3)
+
+ # Empty
+ expected_channel1 = []
+ expected_channel2 = []
+ expected_chain1 = []
+ expected_chain2 = []
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # MVT_DEPOSIT
+ addr = l1.rpc.newaddr()['bech32']
+ txid_deposit = bitcoind.rpc.sendtoaddress(addr, 200000000 / 10**8)
+ bitcoind.generate_block(1, wait_for_mempool=1)
+ sync_blockheight(bitcoind, [l1])
+ vout_deposit = only_one([out['n'] for out in bitcoind.rpc.gettransaction(txid_deposit, False, True)['decoded']['vout'] if out['scriptPubKey']['address'] == addr])
+
+ expected_chain1 += [{'account_id': 'wallet',
+ 'blockheight': 102,
+ 'credit_msat': 200000000000,
+ 'debit_msat': 0,
+ 'output_msat': 200000000000,
+ 'primary_tag': 'deposit',
+ 'extra_tags': [],
+ 'utxo': f"{txid_deposit}:{vout_deposit}"}]
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # MVT_WITHDRAWAL
+ addr = l3.rpc.newaddr()['bech32']
+ withdraw = l1.rpc.withdraw(addr, 100000000)
+ bitcoind.generate_block(1, wait_for_mempool=1)
+ sync_blockheight(bitcoind, [l1])
+ vout_withdrawal = only_one([out['n'] for out in bitcoind.rpc.decoderawtransaction(withdraw['tx'])['vout'] if out['scriptPubKey']['address'] == addr])
+
+ expected_chain1 += [{'account_id': 'external',
+ 'blockheight': 0,
+ 'credit_msat': 100000000000,
+ 'debit_msat': 0,
+ 'output_msat': 100000000000,
+ 'originating_account': 'wallet',
+ 'primary_tag': 'deposit',
+ 'extra_tags': [],
+ 'utxo': f"{withdraw['txid']}:{vout_withdrawal}"},
+ # Spend
+ {'account_id': 'wallet',
+ 'blockheight': 103,
+ 'credit_msat': 0,
+ 'debit_msat': 200000000000,
+ 'primary_tag': 'withdrawal',
+ 'output_msat': 200000000000,
+ 'extra_tags': [],
+ 'spending_txid': withdraw['txid'],
+ 'utxo': f"{txid_deposit}:{vout_deposit}"},
+ # Change
+ {'account_id': 'wallet',
+ 'blockheight': 103,
+ 'credit_msat': 99995433000,
+ 'debit_msat': 0,
+ 'primary_tag': 'deposit',
+ 'extra_tags': [],
+ 'output_msat': 99995433000,
+ 'utxo': f"{withdraw['txid']}:{vout_withdrawal ^ 1}"}]
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # MVT_CHANNEL_OPEN
+ l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
+ fundchannel = l1.rpc.fundchannel(l2.info['id'], 'all')
+ bitcoind.generate_block(1, wait_for_mempool=fundchannel['txid'])
+ wait_for(lambda: all([c['state'] == 'CHANNELD_NORMAL' for c in l1.rpc.listpeerchannels(l2.info['id'])['channels']]))
+ expected_chain1 += [{'account_id': 'wallet',
+ 'blockheight': 104,
+ 'credit_msat': 0,
+ 'debit_msat': 99995433000,
+ 'output_msat': 99995433000,
+ 'primary_tag': 'withdrawal',
+ 'extra_tags': [],
+ 'spending_txid': fundchannel['txid'],
+ 'utxo': f"{withdraw['txid']}:{vout_withdrawal ^ 1}"},
+ {'account_id': 'wallet',
+ 'blockheight': 104,
+ 'credit_msat': 25000000,
+ 'debit_msat': 0,
+ 'output_msat': 25000000,
+ 'primary_tag': 'deposit',
+ 'extra_tags': [],
+ 'utxo': f"{fundchannel['txid']}:{fundchannel['outnum'] ^ 1}"},
+ {'account_id': fundchannel['channel_id'],
+ 'blockheight': 104,
+ 'credit_msat': 99965813000,
+ 'debit_msat': 0,
+ 'output_msat': 99965813000,
+ 'peer_id': l2.info['id'],
+ 'primary_tag': 'channel_open',
+ 'extra_tags': ['opener'],
+ 'utxo': f"{fundchannel['txid']}:{fundchannel['outnum']}"}]
+ expected_chain2 += [{'account_id': fundchannel['channel_id'],
+ 'blockheight': 104,
+ 'credit_msat': 0,
+ 'debit_msat': 0,
+ 'output_msat': 99965813000,
+ 'peer_id': l1.info['id'],
+ 'primary_tag': 'channel_open',
+ 'extra_tags': [],
+ 'utxo': f"{fundchannel['txid']}:{fundchannel['outnum']}"}]
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # MVT_INVOICE
+ inv = l2.rpc.invoice('any', 'test_coinmoves', 'test_coinmoves')
+ l1.rpc.xpay(inv['bolt11'], '1000sat')
+ # Make sure it's fully settled.
+ wait_for(lambda: only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['htlcs'] == [])
+ # We cheat and extract group id.
+ group_id = l1.rpc.listchannelmoves()['channelmoves'][-1]['group_id']
+ expected_channel1 += [{'account_id': fundchannel['channel_id'],
+ 'credit_msat': 0,
+ 'debit_msat': 1000000,
+ 'primary_tag': 'invoice',
+ 'fees_msat': 0,
+ 'payment_hash': inv['payment_hash'],
+ 'group_id': group_id,
+ 'part_id': 1}]
+ expected_channel2 += [{'account_id': fundchannel['channel_id'],
+ 'credit_msat': 1000000,
+ 'debit_msat': 0,
+ 'primary_tag': 'invoice',
+ 'fees_msat': 0,
+ 'payment_hash': inv['payment_hash']}]
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # MVT_PUSHED
+ l3.rpc.connect(l1.info['id'], 'localhost', l1.port)
+ if not EXPERIMENTAL_DUAL_FUND:
+ l3fundchannel = l3.rpc.fundchannel(l1.info['id'], 40000000, push_msat=100000)
+ else:
+ l3fundchannel = l3.rpc.fundchannel(l1.info['id'], 40000000)
+ bitcoind.generate_block(1, wait_for_mempool=1)
+ wait_for(lambda: all([c['state'] == 'CHANNELD_NORMAL' for c in l1.rpc.listpeerchannels(l3.info['id'])['channels']]))
+ expected_chain1 += [{'account_id': l3fundchannel['channel_id'],
+ 'blockheight': 105,
+ 'credit_msat': 0,
+ 'debit_msat': 0,
+ 'output_msat': 40000000000,
+ 'peer_id': l3.info['id'],
+ 'primary_tag': 'channel_open',
+ 'extra_tags': [],
+ 'utxo': f"{l3fundchannel['txid']}:{l3fundchannel['outnum']}"}]
+ if not EXPERIMENTAL_DUAL_FUND:
+ expected_channel1 += [{'account_id': l3fundchannel['channel_id'],
+ 'credit_msat': 100000,
+ 'debit_msat': 0,
+ 'fees_msat': 0,
+ 'primary_tag': 'pushed'}]
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # MVT_ROUTED
+ # Make sure l3 sees l2.
+ bitcoind.generate_block(5)
+ wait_for(lambda: len(l3.rpc.listchannels()['channels']) == 4)
+ inv = l2.rpc.invoice('any', 'test_coinmoves2', 'test_coinmoves2')
+ l3.rpc.xpay(inv['bolt11'], '10000000sat')
+ # Make sure it's fully settled.
+ wait_for(lambda: only_one(l3.rpc.listpeerchannels(l1.info['id'])['channels'])['htlcs'] == [])
+ expected_channel1 += [{'account_id': fundchannel['channel_id'],
+ 'credit_msat': 0,
+ 'debit_msat': 10000000000,
+ 'fees_msat': 100001,
+ 'payment_hash': inv['payment_hash'],
+ 'primary_tag': 'routed'},
+ {'account_id': l3fundchannel['channel_id'],
+ 'credit_msat': 10000100001,
+ 'debit_msat': 0,
+ 'fees_msat': 100001,
+ 'payment_hash': inv['payment_hash'],
+ 'primary_tag': 'routed'}]
+ expected_channel2 += [{'account_id': fundchannel['channel_id'],
+ 'credit_msat': 10000000000,
+ 'debit_msat': 0,
+ 'fees_msat': 0,
+ 'payment_hash': inv['payment_hash'],
+ 'primary_tag': 'invoice'}]
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # MVT_CHANNEL_CLOSE
+ close = l1.rpc.close(fundchannel['channel_id'])
+ bitcoind.generate_block(1, wait_for_mempool=1)
+ sync_blockheight(bitcoind, [l1, l2])
+ # Determining our own output is harder than you might think!
+ l1_addrs = [a['p2tr'] for a in l1.rpc.listaddresses()['addresses'] if 'p2tr' in a]
+ l1_vout_close = only_one([out['n'] for out in bitcoind.rpc.decoderawtransaction(only_one(close['txs']))['vout'] if out['scriptPubKey']['address'] in l1_addrs])
+ expected_chain1 += [{'account_id': 'wallet',
+ 'blockheight': 111,
+ 'credit_msat': 89961918000,
+ 'debit_msat': 0,
+ 'extra_tags': [],
+ 'output_msat': 89961918000,
+ 'primary_tag': 'deposit',
+ 'utxo': f"{only_one(close['txids'])}:{l1_vout_close}"},
+ {'account_id': fundchannel['channel_id'],
+ 'blockheight': 111,
+ 'credit_msat': 0,
+ 'debit_msat': 89964813000,
+ 'extra_tags': [],
+ 'output_count': 2,
+ 'output_msat': 99965813000,
+ 'primary_tag': 'channel_close',
+ 'spending_txid': only_one(close['txids']),
+ 'utxo': f"{fundchannel['txid']}:{fundchannel['outnum']}"},
+ {'account_id': 'external',
+ 'blockheight': 111,
+ 'credit_msat': 10001000000,
+ 'debit_msat': 0,
+ 'extra_tags': [],
+ 'originating_account': fundchannel['channel_id'],
+ 'output_msat': 10001000000,
+ 'primary_tag': 'to_them',
+ 'utxo': f"{only_one(close['txids'])}:{l1_vout_close ^ 1}"}]
+ expected_chain2 += [{'account_id': 'wallet',
+ 'blockheight': 111,
+ 'credit_msat': 10001000000,
+ 'debit_msat': 0,
+ 'extra_tags': [],
+ 'output_msat': 10001000000,
+ 'primary_tag': 'deposit',
+ 'utxo': f"{only_one(close['txids'])}:{fundchannel['outnum']}"},
+ {'account_id': fundchannel['channel_id'],
+ 'blockheight': 111,
+ 'credit_msat': 0,
+ 'debit_msat': 10001000000,
+ 'extra_tags': [],
+ 'output_count': 2,
+ 'output_msat': 99965813000,
+ 'primary_tag': 'channel_close',
+ 'spending_txid': only_one(close['txids']),
+ 'utxo': f"{fundchannel['txid']}:{fundchannel['outnum']}"},
+ {'account_id': 'external',
+ 'blockheight': 111,
+ 'credit_msat': 89961918000,
+ 'debit_msat': 0,
+ 'extra_tags': [],
+ 'originating_account': fundchannel['channel_id'],
+ 'output_msat': 89961918000,
+ 'primary_tag': 'to_them',
+ 'utxo': f"{only_one(close['txids'])}:{fundchannel['outnum'] ^ 1}"}]
+ check_channel_moves(l1, expected_channel1)
+ check_channel_moves(l2, expected_channel2)
+ check_chain_moves(l1, expected_chain1)
+ check_chain_moves(l2, expected_chain2)
+
+ # FIXME:
+ # MVT_PENALTY,
+ # MVT_CHANNEL_TO_US,
+ # MVT_HTLC_TIMEOUT,
+ # MVT_HTLC_FULFILL,
+ # MVT_HTLC_TX,
+ # MVT_TO_WALLET,
+ # MVT_ANCHOR,
+ # MVT_TO_THEM,
+ # MVT_PENALIZED,
+ # MVT_STOLEN,
+ # MVT_TO_MINER,
+ # MVT_LEASE_FEE,
+ # MVT_CHANNEL_PROPOSED,
+ # Extra tags
+ # MVT_SPLICE,
+ # MVT_LEASED,
+ # MVT_STEALABLE,
diff --git a/tests/utils.py b/tests/utils.py
index ad4ff833..fc3f18b1 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -140,6 +140,7 @@ def check_coin_moves(n, account_id, expected_moves, chainparams):
# Stash moves for errors, if needed
_acct_moves = acct_moves
for mv in acct_moves:
+ # Generate tags as single array, which is how bookkeeper presents it
mv['tags'] = [mv['primary_tag']] + mv['extra_tags']
print("{{'type': '{}', 'credit_msat': {}, 'debit_msat': {}, 'tags': '{}' , ['fees_msat'?: '{}']}},"
.format(mv['type'],
Why this scored 18/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.