lightningd: add listhtlcs to list all the HTLCs we know about.

Using `listfowards` for this wrong; expose this directly if people
care (and unlike listforwards, which could be deleted, we have to
remember these while the channel is still open!).

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Changelog-Added: JSON-RPC: `listhtlcs` new command to list all known HTLCS.
This commit is contained in:
Rusty Russell 2022-09-19 10:19:53 +09:30 committed by Christian Decker
parent 311807ff1f
commit 7420a7021f
10 changed files with 461 additions and 17 deletions

View file

@ -49,6 +49,7 @@ MANPAGES := doc/lightning-cli.1 \
doc/lightning-listdatastore.7 \
doc/lightning-listforwards.7 \
doc/lightning-listfunds.7 \
doc/lightning-listhtlcs.7 \
doc/lightning-listinvoices.7 \
doc/lightning-listoffers.7 \
doc/lightning-listpays.7 \

View file

@ -76,6 +76,7 @@ Core Lightning Documentation
lightning-listdatastore <lightning-listdatastore.7.md>
lightning-listforwards <lightning-listforwards.7.md>
lightning-listfunds <lightning-listfunds.7.md>
lightning-listhtlcs <lightning-listhtlcs.7.md>
lightning-listinvoices <lightning-listinvoices.7.md>
lightning-listnodes <lightning-listnodes.7.md>
lightning-listoffers <lightning-listoffers.7.md>

View file

@ -0,0 +1,49 @@
lightning-listhtlcs -- Command for querying HTLCs
=================================================
SYNOPSIS
--------
**listhtlcs** [*id*]
DESCRIPTION
-----------
The **listhtlcs** RPC command gets all HTLCs (which, generally, we
remember for as long as a channel is open, even if they've completed
long ago). If given a short channel id (e.g. 1x2x3) or full 64-byte
hex channel id, it will only list htlcs for that channel (which
must be known).
RETURN VALUE
------------
[comment]: # (GENERATE-FROM-SCHEMA-START)
On success, an object containing **htlcs** is returned. It is an array of objects, where each object contains:
- **short\_channel\_id** (short\_channel\_id): the channel that contains/contained the HTLC
- **id** (u64): the unique, incrementing HTLC id the creator gave this
- **expiry** (u32): the block number where this HTLC expires/expired
- **amount\_msat** (msat): the value of the HTLC
- **direction** (string): out if we offered this to the peer, in if they offered it (one of "out", "in")
- **payment\_hash** (hex): payment hash sought by HTLC (always 64 characters)
- **state** (string): The first 10 states are for `in`, the next 10 are for `out`. (one of "SENT_ADD_HTLC", "SENT_ADD_COMMIT", "RCVD_ADD_REVOCATION", "RCVD_ADD_ACK_COMMIT", "SENT_ADD_ACK_REVOCATION", "RCVD_REMOVE_HTLC", "RCVD_REMOVE_COMMIT", "SENT_REMOVE_REVOCATION", "SENT_REMOVE_ACK_COMMIT", "RCVD_REMOVE_ACK_REVOCATION", "RCVD_ADD_HTLC", "RCVD_ADD_COMMIT", "SENT_ADD_REVOCATION", "SENT_ADD_ACK_COMMIT", "RCVD_ADD_ACK_REVOCATION", "SENT_REMOVE_HTLC", "SENT_REMOVE_COMMIT", "RCVD_REMOVE_REVOCATION", "RCVD_REMOVE_ACK_COMMIT", "SENT_REMOVE_ACK_REVOCATION")
[comment]: # (GENERATE-FROM-SCHEMA-END)
AUTHOR
------
Rusty Russell <<rusty@rustcorp.com.au>> is mainly responsible.
SEE ALSO
--------
lightning-listforwards(7)
RESOURCES
---------
Main web site: <https://github.com/ElementsProject/lightning>
[comment]: # ( SHA256STAMP:6ef16f6e1f54522435130d99f224ca41a38fb3c5bc26886ccdaddc69f1abb946)

View file

@ -0,0 +1,11 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [],
"properties": {
"id": {
"type": "string",
"description": "channel id or short_channel_id"
}
}
}

View file

@ -0,0 +1,84 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": [
"htlcs"
],
"properties": {
"htlcs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"short_channel_id",
"id",
"expiry",
"direction",
"amount_msat",
"payment_hash",
"state"
],
"properties": {
"short_channel_id": {
"type": "short_channel_id",
"description": "the channel that contains/contained the HTLC"
},
"id": {
"type": "u64",
"description": "the unique, incrementing HTLC id the creator gave this"
},
"expiry": {
"type": "u32",
"description": "the block number where this HTLC expires/expired"
},
"amount_msat": {
"type": "msat",
"description": "the value of the HTLC"
},
"direction": {
"type": "string",
"enum": [
"out",
"in"
],
"description": "out if we offered this to the peer, in if they offered it"
},
"payment_hash": {
"type": "hex",
"description": "payment hash sought by HTLC",
"maxLength": 64,
"minLength": 64
},
"state": {
"type": "string",
"enum": [
"SENT_ADD_HTLC",
"SENT_ADD_COMMIT",
"RCVD_ADD_REVOCATION",
"RCVD_ADD_ACK_COMMIT",
"SENT_ADD_ACK_REVOCATION",
"RCVD_REMOVE_HTLC",
"RCVD_REMOVE_COMMIT",
"SENT_REMOVE_REVOCATION",
"SENT_REMOVE_ACK_COMMIT",
"RCVD_REMOVE_ACK_REVOCATION",
"RCVD_ADD_HTLC",
"RCVD_ADD_COMMIT",
"SENT_ADD_REVOCATION",
"SENT_ADD_ACK_COMMIT",
"RCVD_ADD_ACK_REVOCATION",
"SENT_REMOVE_HTLC",
"SENT_REMOVE_COMMIT",
"RCVD_REMOVE_REVOCATION",
"RCVD_REMOVE_ACK_COMMIT",
"SENT_REMOVE_ACK_REVOCATION"
],
"description": "The first 10 states are for `in`, the next 10 are for `out`."
}
}
}
}
}
}

View file

@ -2894,3 +2894,83 @@ static const struct json_command listforwards_command = {
"List all forwarded payments and their information optionally filtering by [status], [in_channel] and [out_channel]"
};
AUTODATA(json_command, &listforwards_command);
static struct command_result *param_channel(struct command *cmd,
const char *name,
const char *buffer,
const jsmntok_t *tok,
struct channel **chan)
{
struct channel_id cid;
struct short_channel_id scid;
if (json_tok_channel_id(buffer, tok, &cid)) {
*chan = channel_by_cid(cmd->ld, &cid);
if (!*chan)
return command_fail_badparam(cmd, name, buffer, tok,
"unknown channel");
return NULL;
} else if (json_to_short_channel_id(buffer, tok, &scid)) {
*chan = any_channel_by_scid(cmd->ld, &scid, true);
if (!*chan)
return command_fail_badparam(cmd, name, buffer, tok,
"unknown channel");
return NULL;
}
return command_fail_badparam(cmd, name, buffer, tok,
"must be channel id or short channel id");
}
static struct command_result *json_listhtlcs(struct command *cmd,
const char *buffer,
const jsmntok_t *obj UNNEEDED,
const jsmntok_t *params)
{
struct json_stream *response;
struct channel *chan;
struct wallet_htlc_iter *i;
struct short_channel_id scid;
u64 htlc_id;
int cltv_expiry;
enum side owner;
struct amount_msat msat;
struct sha256 payment_hash;
enum htlc_state hstate;
if (!param(cmd, buffer, params,
p_opt("id", param_channel, &chan),
NULL))
return command_param_failed();
response = json_stream_success(cmd);
json_array_start(response, "htlcs");
for (i = wallet_htlcs_first(cmd, cmd->ld->wallet, chan,
&scid, &htlc_id, &cltv_expiry, &owner, &msat,
&payment_hash, &hstate);
i;
i = wallet_htlcs_next(cmd->ld->wallet, i,
&scid, &htlc_id, &cltv_expiry, &owner, &msat,
&payment_hash, &hstate)) {
json_object_start(response, NULL);
json_add_short_channel_id(response, "short_channel_id", &scid);
json_add_u64(response, "id", htlc_id);
json_add_u32(response, "expiry", cltv_expiry);
json_add_string(response, "direction",
owner == LOCAL ? "out": "in");
json_add_amount_msat_only(response, "amount_msat", msat);
json_add_sha256(response, "payment_hash", &payment_hash);
json_add_string(response, "state", htlc_state_name(hstate));
json_object_end(response);
}
json_array_end(response);
return command_success(cmd, response);
}
static const struct json_command listhtlcs_command = {
"listhtlcs",
"channels",
json_listhtlcs,
"List all known HTLCS (optionally, just for [id] (scid or channel id))"
};
AUTODATA(json_command, &listhtlcs_command);

View file

@ -3992,12 +3992,13 @@ def test_multichan(node_factory, executor, bitcoind):
# Now fund *second* channel l2->l3 (slightly larger)
bitcoind.rpc.sendtoaddress(l2.rpc.newaddr()['bech32'], 0.1)
bitcoind.generate_block(1)
sync_blockheight(bitcoind, [l2])
sync_blockheight(bitcoind, [l1, l2, l3])
l2.rpc.fundchannel(l3.info['id'], '0.01001btc')
assert(len(only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']) == 2)
assert(len(only_one(l3.rpc.listpeers(l2.info['id'])['peers'])['channels']) == 2)
bitcoind.generate_block(1, wait_for_mempool=1)
sync_blockheight(bitcoind, [l1, l2, l3])
# Make sure new channel is also CHANNELD_NORMAL
wait_for(lambda: [c['state'] for c in only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']] == ["CHANNELD_NORMAL", "CHANNELD_NORMAL"])
@ -4023,9 +4024,9 @@ def test_multichan(node_factory, executor, bitcoind):
'delay': 5,
'channel': scid23a}]
before = only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']
inv = l3.rpc.invoice(100000000, "invoice", "invoice")
l1.rpc.sendpay(route, inv['payment_hash'], payment_secret=inv['payment_secret'])
l1.rpc.waitsendpay(inv['payment_hash'])
inv1 = l3.rpc.invoice(100000000, "invoice", "invoice")
l1.rpc.sendpay(route, inv1['payment_hash'], payment_secret=inv1['payment_secret'])
l1.rpc.waitsendpay(inv1['payment_hash'])
# Wait until HTLCs fully settled
wait_for(lambda: [c['htlcs'] for c in only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']] == [[], []])
after = only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']
@ -4049,9 +4050,9 @@ def test_multichan(node_factory, executor, bitcoind):
before = only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']
route[1]['channel'] = scid23b
inv = l3.rpc.invoice(100000000, "invoice2", "invoice2")
l1.rpc.sendpay(route, inv['payment_hash'], payment_secret=inv['payment_secret'])
l1.rpc.waitsendpay(inv['payment_hash'])
inv2 = l3.rpc.invoice(100000000, "invoice2", "invoice2")
l1.rpc.sendpay(route, inv2['payment_hash'], payment_secret=inv2['payment_secret'])
l1.rpc.waitsendpay(inv2['payment_hash'])
# Wait until HTLCs fully settled
wait_for(lambda: [c['htlcs'] for c in only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']] == [[], []])
after = only_one(l2.rpc.listpeers(l3.info['id'])['peers'])['channels']
@ -4062,6 +4063,7 @@ def test_multichan(node_factory, executor, bitcoind):
# Make sure gossip works.
bitcoind.generate_block(5)
sync_blockheight(bitcoind, [l1, l2, l3])
wait_for(lambda: len(l1.rpc.listchannels(source=l3.info['id'])['channels']) == 2)
@ -4084,6 +4086,7 @@ def test_multichan(node_factory, executor, bitcoind):
l2.rpc.close(scid23b)
bitcoind.generate_block(1, wait_for_mempool=1)
sync_blockheight(bitcoind, [l1, l2, l3])
# Gossip works as expected.
wait_for(lambda: len(l1.rpc.listchannels(source=l3.info['id'])['channels']) == 1)
@ -4091,9 +4094,9 @@ def test_multichan(node_factory, executor, bitcoind):
# We can actually pay by *closed* scid (at least until it's completely forgotten)
route[1]['channel'] = scid23a
inv = l3.rpc.invoice(100000000, "invoice3", "invoice3")
l1.rpc.sendpay(route, inv['payment_hash'], payment_secret=inv['payment_secret'])
l1.rpc.waitsendpay(inv['payment_hash'])
inv3 = l3.rpc.invoice(100000000, "invoice3", "invoice3")
l1.rpc.sendpay(route, inv3['payment_hash'], payment_secret=inv3['payment_secret'])
l1.rpc.waitsendpay(inv3['payment_hash'])
# Restart with multiple channels works.
l3.restart()
@ -4103,8 +4106,48 @@ def test_multichan(node_factory, executor, bitcoind):
except RpcError:
wait_for(lambda: only_one(l3.rpc.listpeers(l2.info['id'])['peers'])['connected'])
inv = l3.rpc.invoice(100000000, "invoice4", "invoice4")
l1.rpc.pay(inv['bolt11'])
inv4 = l3.rpc.invoice(100000000, "invoice4", "invoice4")
l1.rpc.pay(inv4['bolt11'])
# A good place to test listhtlcs!
wait_for(lambda: all([h['state'] == 'RCVD_REMOVE_ACK_REVOCATION' for h in l1.rpc.listhtlcs()['htlcs']]))
l1htlcs = l1.rpc.listhtlcs()['htlcs']
assert l1htlcs == l1.rpc.listhtlcs(scid12)['htlcs']
assert l1htlcs == [{"short_channel_id": scid12,
"id": 0,
"expiry": 117,
"direction": "out",
"amount_msat": Millisatoshi(100001001),
"payment_hash": inv1['payment_hash'],
"state": "RCVD_REMOVE_ACK_REVOCATION"},
{"short_channel_id": scid12,
"id": 1,
"expiry": 117,
"direction": "out",
"amount_msat": Millisatoshi(100001001),
"payment_hash": inv2['payment_hash'],
"state": "RCVD_REMOVE_ACK_REVOCATION"},
{"short_channel_id": scid12,
"id": 2,
"expiry": 123,
"direction": "out",
"amount_msat": Millisatoshi(100001001),
"payment_hash": inv3['payment_hash'],
"state": "RCVD_REMOVE_ACK_REVOCATION"},
{"short_channel_id": scid12,
"id": 3,
"expiry": 123,
"direction": "out",
"amount_msat": Millisatoshi(100001001),
"payment_hash": inv4['payment_hash'],
"state": "RCVD_REMOVE_ACK_REVOCATION"}]
# Reverse direction, should match l2's view of channel.
for h in l1htlcs:
h['direction'] = 'in'
h['state'] = 'SENT_REMOVE_ACK_REVOCATION'
assert l2.rpc.listhtlcs(scid12)['htlcs'] == l1htlcs
@pytest.mark.developer("dev-no-reconnect required")

View file

@ -2,7 +2,7 @@ from bitcoin.rpc import RawProxy
from decimal import Decimal
from fixtures import * # noqa: F401,F403
from fixtures import LightningNode, TEST_NETWORK
from pyln.client import RpcError
from pyln.client import RpcError, Millisatoshi
from threading import Event
from pyln.testing.utils import (
DEVELOPER, TIMEOUT, VALGRIND, DEPRECATED_APIS, sync_blockheight, only_one,
@ -2379,15 +2379,15 @@ def test_listfunds(node_factory):
assert open_txid in txids
def test_listforwards(node_factory, bitcoind):
"""Test listfunds command."""
def test_listforwards_and_listhtlcs(node_factory, bitcoind):
"""Test listforwards and listhtlcs commands."""
l1, l2, l3, l4 = node_factory.get_nodes(4, opts=[{}, {}, {}, {}])
l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
l2.rpc.connect(l3.info['id'], 'localhost', l3.port)
l2.rpc.connect(l4.info['id'], 'localhost', l4.port)
c12, _ = l1.fundchannel(l2, 10**5)
c12, c12res = l1.fundchannel(l2, 10**5)
c23, _ = l2.fundchannel(l3, 10**5)
c24, _ = l2.fundchannel(l4, 10**5)
@ -2406,7 +2406,7 @@ def test_listforwards(node_factory, bitcoind):
failed_inv = l3.rpc.invoice(4000, 'failed', 'desc')
failed_route = l1.rpc.getroute(l3.info['id'], 4000, 1)['route']
l2.rpc.close(c23, 1)
l2.rpc.close(c23)
with pytest.raises(RpcError):
l1.rpc.sendpay(failed_route, failed_inv['payment_hash'], payment_secret=failed_inv['payment_secret'])
@ -2447,6 +2447,52 @@ def test_listforwards(node_factory, bitcoind):
c24_forwards = l2.rpc.listforwards(out_channel=c24)['forwards']
assert len(c24_forwards) == 1
# listhtlcs on l1 is the same with or without id specifiers
c1htlcs = l1.rpc.listhtlcs()['htlcs']
assert l1.rpc.listhtlcs(c12)['htlcs'] == c1htlcs
assert l1.rpc.listhtlcs(c12res['channel_id'])['htlcs'] == c1htlcs
c1htlcs.sort(key=lambda h: h['id'])
assert [h['id'] for h in c1htlcs] == [0, 1, 2]
assert [h['short_channel_id'] for h in c1htlcs] == [c12] * 3
assert [h['amount_msat'] for h in c1htlcs] == [Millisatoshi(1001),
Millisatoshi(2001),
Millisatoshi(4001)]
assert [h['direction'] for h in c1htlcs] == ['out'] * 3
assert [h['state'] for h in c1htlcs] == ['RCVD_REMOVE_ACK_REVOCATION'] * 3
# These should be a mirror!
c2c1htlcs = l2.rpc.listhtlcs(c12)['htlcs']
for h in c2c1htlcs:
assert h['state'] == 'SENT_REMOVE_ACK_REVOCATION'
assert h['direction'] == 'in'
h['state'] = 'RCVD_REMOVE_ACK_REVOCATION'
h['direction'] = 'out'
assert c2c1htlcs == c1htlcs
# One channel at a time should result in all htlcs.
allhtlcs = l2.rpc.listhtlcs()['htlcs']
parthtlcs = (l2.rpc.listhtlcs(c12)['htlcs']
+ l2.rpc.listhtlcs(c23)['htlcs']
+ l2.rpc.listhtlcs(c24)['htlcs'])
assert len(allhtlcs) == len(parthtlcs)
for h in allhtlcs:
assert h in parthtlcs
# Now, close and forget.
l2.rpc.close(c24)
l2.rpc.close(c12)
bitcoind.generate_block(100, wait_for_mempool=3)
# Once channels are gone, htlcs are gone.
for n in (l1, l2, l3, l4):
# They might reconnect, but still will have no channels
wait_for(lambda: all(p['channels'] == [] for p in n.rpc.listpeers()['peers']))
assert n.rpc.listhtlcs() == {'htlcs': []}
# But forwards are not forgotten!
assert l2.rpc.listforwards()['forwards'] == all_forwards
@pytest.mark.openchannel('v1')
def test_version_reexec(node_factory, bitcoind):

View file

@ -5146,3 +5146,96 @@ struct db_stmt *wallet_datastore_next(const tal_t *ctx,
return stmt;
}
/* We use a different query form if we only care about a single channel. */
struct wallet_htlc_iter {
struct db_stmt *stmt;
/* Non-zero if they specified it */
struct short_channel_id scid;
};
struct wallet_htlc_iter *wallet_htlcs_first(const tal_t *ctx,
struct wallet *w,
const struct channel *chan,
struct short_channel_id *scid,
u64 *htlc_id,
int *cltv_expiry,
enum side *owner,
struct amount_msat *msat,
struct sha256 *payment_hash,
enum htlc_state *hstate)
{
struct wallet_htlc_iter *i = tal(ctx, struct wallet_htlc_iter);
if (chan) {
i->scid = *channel_scid_or_local_alias(chan);
assert(i->scid.u64 != 0);
assert(chan->dbid != 0);
i->stmt = db_prepare_v2(w->db,
SQL("SELECT h.channel_htlc_id"
", h.cltv_expiry"
", h.direction"
", h.msatoshi"
", h.payment_hash"
", h.hstate"
" FROM channel_htlcs h"
" WHERE channel_id = ?"));
db_bind_u64(i->stmt, 0, chan->dbid);
} else {
i->scid.u64 = 0;
i->stmt = db_prepare_v2(w->db,
SQL("SELECT channels.scid"
", channels.alias_local"
", h.channel_htlc_id"
", h.cltv_expiry"
", h.direction"
", h.msatoshi"
", h.payment_hash"
", h.hstate"
" FROM channel_htlcs h"
" JOIN channels ON channels.id = h.channel_id"));
}
/* FIXME: db_prepare should take ctx! */
tal_steal(i, i->stmt);
db_query_prepared(i->stmt);
return wallet_htlcs_next(w, i,
scid, htlc_id, cltv_expiry, owner, msat,
payment_hash, hstate);
}
struct wallet_htlc_iter *wallet_htlcs_next(struct wallet *w,
struct wallet_htlc_iter *iter,
struct short_channel_id *scid,
u64 *htlc_id,
int *cltv_expiry,
enum side *owner,
struct amount_msat *msat,
struct sha256 *payment_hash,
enum htlc_state *hstate)
{
if (!db_step(iter->stmt))
return tal_free(iter);
if (iter->scid.u64 != 0)
*scid = iter->scid;
else {
if (db_col_is_null(iter->stmt, "channels.scid"))
db_col_scid(iter->stmt, "channels.alias_local", scid);
else {
db_col_scid(iter->stmt, "channels.scid", scid);
db_col_ignore(iter->stmt, "channels.alias_local");
}
}
*htlc_id = db_col_u64(iter->stmt, "h.channel_htlc_id");
if (db_col_int(iter->stmt, "h.direction") == DIRECTION_INCOMING)
*owner = REMOTE;
else
*owner = LOCAL;
db_col_amount_msat(iter->stmt, "h.msatoshi", msat);
db_col_sha256(iter->stmt, "h.payment_hash", payment_hash);
*cltv_expiry = db_col_int(iter->stmt, "h.cltv_expiry");
*hstate = db_col_int(iter->stmt, "h.hstate");
return iter;
}

View file

@ -1632,4 +1632,40 @@ struct db_stmt *wallet_datastore_next(const tal_t *ctx,
const u8 **data,
u64 *generation);
/**
* Iterate through the htlcs table.
* @w: the wallet
* @chan: optional channel to filter by
*
* Returns pointer to hand as @iter to wallet_htlcs_next(), or NULL.
* If you choose not to call wallet_htlcs_next() you must free it!
*/
struct wallet_htlc_iter *wallet_htlcs_first(const tal_t *ctx,
struct wallet *w,
const struct channel *chan,
struct short_channel_id *scid,
u64 *htlc_id,
int *cltv_expiry,
enum side *owner,
struct amount_msat *msat,
struct sha256 *payment_hash,
enum htlc_state *hstate);
/**
* Iterate through the htlcs table.
* @w: the wallet
* @iter: the previous iter.
*
* Returns pointer to hand as @iter to wallet_htlcs_next(), or NULL.
* If you choose not to call wallet_htlcs_next() you must free it!
*/
struct wallet_htlc_iter *wallet_htlcs_next(struct wallet *w,
struct wallet_htlc_iter *iter,
struct short_channel_id *scid,
u64 *htlc_id,
int *cltv_expiry,
enum side *owner,
struct amount_msat *msat,
struct sha256 *payment_hash,
enum htlc_state *hstate);
#endif /* LIGHTNING_WALLET_WALLET_H */