lightningd: separate coin_movement `tags` array into `primary_tag` and `extra_tags`.
What changed, and why it matters
This commit is a routine API cleanup, not a security fix. It splits the coin_movement notification's old 'tags' array into two new fields: 'primary_tag' and 'extra_tags'. The old 'tags' array is kept for backward compatibility but marked as deprecated. Nothing in the change fixes a vulnerability or changes how funds are protected.
No security action required. Developers consuming coin_movement notifications should plan to migrate from 'tags' to 'primary_tag'/'extra_tags' before the v26.09 removal window.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch refactors JSON-RPC coin_movement notifications in lightningd/notification.c so that chain and channel movement events emit ‘primary_tag’ plus ‘extra_tags’ instead of only a single ‘tags’ array. The legacy ‘tags’ array is still emitted if deprecated output is enabled. The bookkeeper plugin (plugins/bkpr/bookkeeper.c) and test helpers (tests/utils.py) are updated to consume the new fields. Documentation and the deprecation table are updated accordingly. No memory-safety, cryptographic, or authorization changes are present.
Changed components
lightningd/notification.cplugins/bkpr/bookkeeper.ctests/utils.pydoc/developers-guide/deprecated-features.mddoc/developers-guide/plugin-development/event-notifications.mdInspect captured patch +70 / −37
diff --git a/doc/developers-guide/deprecated-features.md b/doc/developers-guide/deprecated-features.md
index 2ed1fb06..1ac420c8 100644
--- a/doc/developers-guide/deprecated-features.md
+++ b/doc/developers-guide/deprecated-features.md
@@ -18,6 +18,7 @@ hidden: false
| listpeerchannels.max_total_htlc_in_msat | Field | v25.02 | v26.03 | Use our_max_total_htlc_out_msat |
| wait.details | Field | v25.05 | v26.06 | Use subsystem-specific object instead |
| channel_state_changed.old_state.unknown | Notification Field | v25.05 | v26.03 | Value "unknown" is deprecated: field will be omitted instead |
+| coin_movement.tags | Notification Field | v25.09 | v26.09 | Use `primary_tag` (first tag) and `extra_tags` instead |
Inevitably there are features which need to change: either to be generalized, or removed when they can no longer be supported.
diff --git a/doc/developers-guide/plugin-development/event-notifications.md b/doc/developers-guide/plugin-development/event-notifications.md
index 37d69a77..c16c1e83 100644
--- a/doc/developers-guide/plugin-development/event-notifications.md
+++ b/doc/developers-guide/plugin-development/event-notifications.md
@@ -339,7 +339,8 @@ A notification for topic `coin_movement` is sent to record the movement of coins
"output_msat": 2000000000, // ('chain_mvt' only)
"output_count": 2, // ('chain_mvt' only, typically only channel closes)
"fees_msat": 382, // ('channel_mvt' only)
- "tags": ["deposit"],
+ "primary_tag": "deposit",
+ "extra_tags": [],
"blockheight":102, // 'chain_mvt' only
"timestamp":1585948198,
"coin_type":"bc"
@@ -376,7 +377,7 @@ _Only_ tagged on external events (deposits/withdrawals to an external party).
`fees` is an HTLC annotation for the amount of fees either paid or earned. For "invoice" tagged events, the fees are the total fees paid to send that payment. The end amount can be found by subtracting the total fees from the `debited` amount. For "routed" tagged events, both the debit/credit contain fees. Technically routed debits are the 'fee generating' event, however we include them on routed credits as well.
-`tag` is a movement descriptor. Current tags are as follows:
+`primary_tag` is a movement descriptor. Current primary tags are as follows:
- `deposit`: funds deposited
- `withdrawal`: funds withdrawn
@@ -391,15 +392,21 @@ _Only_ tagged on external events (deposits/withdrawals to an external party).
- `htlc_fulfill`: on-chian htlc fulfill output
- `htlc_tx`: on-chain htlc tx has happened
- `to_wallet`: output being spent into our wallet
-- `ignored`: output is being ignored
- `anchor`: an anchor output
- `to_them`: output intended to peer's wallet
- `penalized`: output we've 'lost' due to a penalty (failed cheat attempt)
- `stolen`: output we've 'lost' due to peer's cheat
- `to_miner`: output we've burned to miner (OP_RETURN)
-- `opener`: tags channel_open, we are the channel opener
- `lease_fee`: amount paid as lease fee
-- `leased`: tags channel_open, channel contains leased funds
+- `channel_proposed`: a zero-conf channel
+
+`extra_tags` is zero or more additional tags. Current extra tags are as follows:
+
+- `ignored`: output is being ignored
+- `opener`: tags `channel_open` or `channel_proposed`, we are the channel opener
+- `stealable`: funds can be taken by the other party
+- `leased`: tags `channel_open` or `channel_proposed`, channel contains leased funds
+- `splice`: a channel close due to splice operation.
`blockheight` is the block the txid is included in. `channel_mvt`s will be null, so will the blockheight for withdrawals to external parties (we issue these events when we send the tx containing them, before they're included in the chain).
diff --git a/lightningd/notification.c b/lightningd/notification.c
index d45123f8..e6722891 100644
--- a/lightningd/notification.c
+++ b/lightningd/notification.c
@@ -455,12 +455,37 @@ static void json_add_mvt_account_id(struct json_stream *stream,
json_add_string(stream, fieldname, account_id->alt_account);
}
+static void add_movement_tags(struct json_stream *stream,
+ struct lightningd *ld,
+ const struct mvt_tags tags,
+ bool extra_tags_field)
+{
+ const char **tagstrs = mvt_tag_strs(tmpctx, tags);
+
+ if (lightningd_deprecated_out_ok(ld, ld->deprecated_ok,
+ "coin_movement", "tags",
+ "v25.05", "v26.09")) {
+ json_array_start(stream, "tags");
+ for (size_t i = 0; i < tal_count(tagstrs); i++)
+ json_add_string(stream, NULL, tagstrs[i]);
+ json_array_end(stream);
+ }
+
+ json_add_string(stream, "primary_tag", tagstrs[0]);
+ if (extra_tags_field) {
+ json_array_start(stream, "extra_tags");
+ for (size_t i = 1; i < tal_count(tagstrs); i++)
+ json_add_string(stream, NULL, tagstrs[i]);
+ json_array_end(stream);
+ } else {
+ assert(tal_count(tagstrs) == 1);
+ }
+}
+
static void chain_movement_notification_serialize(struct json_stream *stream,
struct lightningd *ld,
const struct chain_coin_mvt *chain_mvt)
{
- const char **tags;
-
json_add_num(stream, "version", COIN_MVT_VERSION);
json_add_string(stream, "type", "chain_mvt");
json_add_node_id(stream, "node_id", &ld->our_nodeid);
@@ -497,11 +522,7 @@ static void chain_movement_notification_serialize(struct json_stream *stream,
if (chain_mvt->output_count > 0)
json_add_num(stream, "output_count", chain_mvt->output_count);
- json_array_start(stream, "tags");
- tags = mvt_tag_strs(tmpctx, chain_mvt->tags);
- for (size_t i = 0; i < tal_count(tags); i++)
- json_add_string(stream, NULL, tags[i]);
- json_array_end(stream);
+ add_movement_tags(stream, ld, chain_mvt->tags, true);
json_add_u32(stream, "blockheight", chain_mvt->blockheight);
json_add_u32(stream, "timestamp", time_now().ts.tv_sec);
@@ -510,10 +531,9 @@ static void chain_movement_notification_serialize(struct json_stream *stream,
static void channel_movement_notification_serialize(struct json_stream *stream,
struct lightningd *ld,
- const struct channel_coin_mvt *chan_mvt)
+ const struct channel_coin_mvt *chan_mvt,
+ bool extra_tags_field)
{
- const char **tags;
-
json_add_num(stream, "version", COIN_MVT_VERSION);
json_add_string(stream, "type", "channel_mvt");
json_add_node_id(stream, "node_id", &ld->our_nodeid);
@@ -529,11 +549,7 @@ static void channel_movement_notification_serialize(struct json_stream *stream,
json_add_amount_msat(stream, "debit_msat", chan_mvt->debit);
json_add_amount_msat(stream, "fees_msat", chan_mvt->fees);
- json_array_start(stream, "tags");
- tags = mvt_tag_strs(tmpctx, chan_mvt->tags);
- for (size_t i = 0; i < tal_count(tags); i++)
- json_add_string(stream, NULL, tags[i]);
- json_array_end(stream);
+ add_movement_tags(stream, ld, chan_mvt->tags, extra_tags_field);
json_add_u32(stream, "timestamp", time_now().ts.tv_sec);
json_add_string(stream, "coin_type", chainparams->lightning_hrp);
@@ -547,7 +563,8 @@ void notify_channel_mvt(struct lightningd *ld,
struct jsonrpc_notification *n = notify_start(ld, "coin_movement");
if (!n)
return;
- channel_movement_notification_serialize(n->stream, ld, chan_mvt);
+ /* Adding (empty) extra_tags field unifies this with notify_chain_mvt */
+ channel_movement_notification_serialize(n->stream, ld, chan_mvt, true);
notify_send(ld, n);
}
diff --git a/plugins/bkpr/bookkeeper.c b/plugins/bkpr/bookkeeper.c
index fb331c19..d76e7d99 100644
--- a/plugins/bkpr/bookkeeper.c
+++ b/plugins/bkpr/bookkeeper.c
@@ -1765,16 +1765,20 @@ static char *parse_tags(const tal_t *ctx,
enum mvt_tag **tags)
{
size_t i;
- const jsmntok_t *tag_tok,
- *tags_tok = json_get_member(buf, tok, "tags");
-
- if (tags_tok == NULL || tags_tok->type != JSMN_ARRAY)
- return "Invalid/missing 'tags' field";
-
- *tags = tal_arr(ctx, enum mvt_tag, tags_tok->size);
- json_for_each_arr(i, tag_tok, tags_tok) {
- if (!json_to_coin_mvt_tag(buf, tag_tok, &(*tags)[i]))
- return "Unable to parse 'tags'";
+ const jsmntok_t *extras_tok,
+ *tag_tok = json_get_member(buf, tok, "primary_tag");
+
+ if (tag_tok == NULL)
+ return "missing 'primary_tag' field";
+ *tags = tal_arr(ctx, enum mvt_tag, 1);
+ if (!json_to_coin_mvt_tag(buf, tag_tok, &(*tags)[0]))
+ return "Unable to parse 'primary_tag'";
+
+ extras_tok = json_get_member(buf, tok, "extra_tags");
+ tal_resize(tags, 1 + extras_tok->size);
+ json_for_each_arr(i, tag_tok, extras_tok) {
+ if (!json_to_coin_mvt_tag(buf, tag_tok, &(*tags)[i + 1]))
+ return "Unable to parse 'extra_tags'";
}
return NULL;
diff --git a/tests/utils.py b/tests/utils.py
index 6c701021..5d560954 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -140,11 +140,12 @@ 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:
+ mv['tags'] = [mv['primary_tag']] + mv['extra_tags']
print("{{'type': '{}', 'credit_msat': {}, 'debit_msat': {}, 'tags': '{}' , ['fees_msat'?: '{}']}},"
.format(mv['type'],
Millisatoshi(mv['credit_msat']).millisatoshis,
Millisatoshi(mv['debit_msat']).millisatoshis,
- sorted(mv['tags']),
+ mv['tags'],
mv['fees_msat'] if 'fees_msat' in mv else ''))
if mv['version'] != 2:
raise ValueError(f'version not 2 {mv}')
@@ -261,6 +262,9 @@ def matchup_events(u_set, evs, chans, tag_list):
if len(u_set) == 0:
raise ValueError(f"utxo-set is empty. exp {evs}, actual {u_set}")
+ def get_tags(utxo):
+ return [utxo['primary_tag']] + utxo['extra_tags']
+
txid = u_set[0][0]['utxo_txid']
# Stash the set for logging at end, if error
_u_set = u_set
@@ -277,7 +281,7 @@ def matchup_events(u_set, evs, chans, tag_list):
else:
acct = ev[0]
- if u[0]['account_id'] != acct or sorted(u[0]['tags']) != sorted(ev[1]):
+ if u[0]['account_id'] != acct or get_tags(u[0]) != ev[1]:
continue
if ev[2] is None:
@@ -289,7 +293,7 @@ def matchup_events(u_set, evs, chans, tag_list):
# ugly hack to annotate two possible futures for a utxo
if type(ev[2]) is tuple:
- tag = u[1]['tags'] if u[1] else u[1]
+ tag = get_tags(u[1]) if u[1] else u[1]
if tag not in [x[0] for x in ev[2]]:
raise ValueError(f"Unable to find {tag} in event set {ev}")
if not u[1]:
@@ -297,14 +301,14 @@ def matchup_events(u_set, evs, chans, tag_list):
u_set.remove(u)
break
for x in ev[2]:
- if x[0] == u[1]['tags'] and 'to_miner' not in u[1]['tags']:
+ if x[0] == get_tags(u[1]) and 'to_miner' not in get_tags(u[1]):
# Save the 'spent to' txid in the tag-list
tag_list[x[1]] = u[1]['txid']
else:
- if sorted(ev[2]) != sorted(u[1]['tags']):
+ if ev[2] != get_tags(u[1]):
raise ValueError(f"tags dont' match. exp {ev}, actual ({u[1]}) full utxo info: {u}")
# Save the 'spent to' txid in the tag-list
- if 'to_miner' not in u[1]['tags']:
+ if 'to_miner' not in get_tags(u[1]):
tag_list[ev[3]] = u[1]['txid']
found = True
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.