fuzz-tests: Add a test for `full_channel` operations
What changed, and why it matters
This commit adds a new automated fuzz test for Core Lightning's channel state machine. It is purely a test/instrumentation addition: it creates no new production code, changes no protocol behavior, and does not fix any bug. The change is defensive in nature, intended to help find future bugs in HTLC handling.
No security action required. Treat as normal test infrastructure. Optionally review the fuzz harness for correctness if integrating into CI.
Security signals we found
Adds fuzz coverage for HTLC state machine (defensive testing, not a vulnerability fix)
No changes to channeld/full_channel.c or consensus-critical logic
No changelog security claim or CVE reference present
Evidence from the diff
The commit introduces tests/fuzz/fuzz-full_channel.c, a libFuzzer harness that mirrors channeld/test/run-full_channel. It sets up paired local/remote channel objects and drives operations (ADD_HTLC, FULFILL_HTLC, FAIL_HTLC, UPDATE_FEE, UPDATE_BLOCKHEIGHT, and commitment exchanges) from fuzzer-controlled input. The Makefile is updated to add the new fuzz target and simplify an unrelated target’s dependency list. No production code is modified.
Changed components
tests/fuzz/fuzz-full_channel.ctests/fuzz/MakefileInspect captured patch +388 / −9
diff --git a/tests/fuzz/Makefile b/tests/fuzz/Makefile
index f016ec91..1813aa12 100644
--- a/tests/fuzz/Makefile
+++ b/tests/fuzz/Makefile
@@ -9,15 +9,8 @@ tests/fuzz/fuzz-hmac-sha256: LDLIBS += -lcrypto
tests/fuzz/fuzz-wire-*.o: tests/fuzz/wire.h
tests/fuzz/fuzz-bolt12-*.o: tests/fuzz/bolt12.h
-tests/fuzz/fuzz-handle_onion_message: common/sphinx.o \
- common/blindedpath.o \
- common/hmac.o \
- common/blinding.o \
- common/onionreply.o \
- common/dev_disconnect.o \
- common/onion_message_parse.o \
- connectd/onion_message.o \
- connectd/connectd_wiregen.o
+tests/fuzz/fuzz-handle_onion_message: connectd/onion_message.o connectd/connectd_wiregen.o
+tests/fuzz/fuzz-full_channel: channeld/full_channel.o channeld/commit_tx.o
FUZZ_TARGETS_SRC := $(wildcard tests/fuzz/fuzz-*.c)
FUZZ_TARGETS_OBJS := $(FUZZ_TARGETS_SRC:.c=.o)
diff --git a/tests/fuzz/fuzz-full_channel.c b/tests/fuzz/fuzz-full_channel.c
new file mode 100644
index 00000000..7a5a6a84
--- /dev/null
+++ b/tests/fuzz/fuzz-full_channel.c
@@ -0,0 +1,386 @@
+/* This test is a fuzz version of channeld/test/run-full_channel.
+ * A lot of the helper functions and setup have been copied over
+ * from that test.
+ */
+#include "config.h"
+#include <fcntl.h>
+#include <common/blockheight_states.h>
+#include <ccan/ccan/array_size/array_size.h>
+#include <common/channel_type.h>
+#include <common/fee_states.h>
+#include <common/htlc_wire.h>
+#include <common/setup.h>
+#include <common/status.h>
+#include <channeld/full_channel.h>
+#include <tests/fuzz/libfuzz.h>
+
+/* MOCKS START */
+const struct siphash_seed *siphash_seed(void)
+{
+ struct siphash_seed *siphashseed = tal(tmpctx, struct siphash_seed);
+ memset(siphashseed, 1, sizeof(*siphashseed));
+ return siphashseed;
+}
+/* MOCKS END */
+
+#define MAX_SATS ((u64)WALLY_SATOSHI_PER_BTC * WALLY_BTC_MAX)
+#define MAX_MSATS (MAX_SATS * 1000)
+#define MAX_HTLCS 1000000
+
+static struct amount_msat fromwire_amount_msat_bounded(const u8 **cursor, size_t *max)
+{
+ u64 amt = fromwire_u64(cursor, max) % (MAX_MSATS + 1);
+ return amount_msat(amt);
+}
+
+static u32 fromwire_bounded_feerate(const u8 **cursor, size_t *max)
+{
+ /* fee_states doesn't allow a feerate < 253. */
+ u32 min_feerate = 253, max_feerate = 1000000;
+ return (min_feerate + fromwire_u32(cursor, max) % max_feerate);
+}
+
+static struct bitcoin_txid txid_from_hex(const char *hex)
+{
+ struct bitcoin_txid txid;
+ if (!bitcoin_txid_from_hex(hex, strlen(hex), &txid))
+ assert(false && "bitcoin_txid_from_hex failed");
+ return txid;
+}
+
+static struct pubkey pubkey_from_hex(const char *hex)
+{
+ struct pubkey pubkey;
+ if (strstarts(hex, "0x"))
+ hex += 2;
+ if (!pubkey_from_hexstr(hex, strlen(hex), &pubkey))
+ assert(false && "pubkey_from_hexstr failed");
+ return pubkey;
+}
+
+/* These helpers invoke the underlying full_channel API and then drive
+ * the full commitment dance (the four steps of sending_commit ->
+ * recv_revoke_and_ack -> recv_commit -> sending_revoke_and_ack)
+ * in the correct opener vs. accepter order.
+ */
+static void exchange_commits(struct channel *channel, enum side side, const struct htlc ***changed_htlcs)
+{
+ if (side == LOCAL) {
+ channel_sending_commit(channel, changed_htlcs);
+ channel_rcvd_revoke_and_ack(channel, changed_htlcs);
+ channel_rcvd_commit(channel, changed_htlcs);
+ channel_sending_revoke_and_ack(channel);
+ } else {
+ channel_rcvd_commit(channel, changed_htlcs);
+ channel_sending_revoke_and_ack(channel);
+ channel_sending_commit(channel, changed_htlcs);
+ channel_rcvd_revoke_and_ack(channel, changed_htlcs);
+ }
+}
+
+/* Randomize the commitment dance order. */
+static void fuzz_exchange_commits(struct channel *channel, const struct htlc ***changed_htlcs,
+ const u8 **cursor, size_t *max)
+{
+ int order[] = {0, 1, 2, 3};
+
+ /* Shuffle the call order using fuzzer data. */
+ for (size_t i = ARRAY_SIZE(order) - 1; i > 0 && *max > 0; i--) {
+ size_t j = fromwire_u8(cursor, max) % (i + 1);
+ int temp = order[i];
+ order[i] = order[j];
+ order[j] = temp;
+ }
+
+ for (size_t i = 0; i < ARRAY_SIZE(order); i++) {
+ switch (order[i]) {
+ case 0:
+ channel_sending_commit(channel, changed_htlcs);
+ break;
+ case 1:
+ channel_rcvd_revoke_and_ack(channel, changed_htlcs);
+ break;
+ case 2:
+ channel_rcvd_commit(channel, changed_htlcs);
+ break;
+ case 3:
+ channel_sending_revoke_and_ack(channel);
+ break;
+ }
+ }
+}
+
+static bool update_feerate(struct channel *channel, u32 feerate)
+{
+ if (!channel_update_feerate(channel, feerate))
+ return false;
+ return true;
+}
+
+static void update_blockheight(struct channel *channel, u32 blockheight)
+{
+ channel_update_blockheight(channel, blockheight);
+}
+
+static bool add_htlc(struct channel *channel, enum side sender, u64 id,
+ const struct preimage *preimage,
+ struct amount_msat msatoshi, u32 cltv)
+{
+ struct sha256 rhash;
+ u8 *dummy_routing;
+
+ dummy_routing = tal_arr(channel, u8, TOTAL_PACKET_SIZE(ROUTING_INFO_SIZE));
+ sha256(&rhash, preimage, sizeof(*preimage));
+
+ if (channel_add_htlc(channel, sender, id, msatoshi, cltv, &rhash,
+ dummy_routing, NULL, NULL, NULL, NULL, true) != CHANNEL_ERR_ADD_OK) {
+ tal_free(dummy_routing);
+ return false;
+ }
+
+ tal_free(dummy_routing);
+ return true;
+}
+
+static bool fulfill_htlc(struct channel *channel, enum side original_sender,
+ u64 id, const struct preimage *preimage)
+{
+ if (channel_fulfill_htlc(channel, original_sender, id, preimage, NULL)
+ != CHANNEL_ERR_REMOVE_OK)
+ return false;
+ return true;
+}
+
+static bool fail_htlc(struct channel *channel, enum side original_sender, u64 id)
+{
+ if (channel_fail_htlc(channel, original_sender, id, NULL)
+ != CHANNEL_ERR_REMOVE_OK)
+ return false;
+ return true;
+}
+
+static void init_channels(const tal_t *ctx, const u8 **cursor, size_t *max,
+ struct pubkey *local_per_commitment_point,
+ struct bitcoin_outpoint *funding,
+ struct amount_sat *funding_amount,
+ u32 *blockheight,
+ struct channel **lchannel,
+ struct channel **rchannel)
+{
+ struct channel_id cid;
+ u32 feerate_per_kw[NUM_SIDES] = {fromwire_bounded_feerate(cursor, max),
+ fromwire_bounded_feerate(cursor, max)};
+ struct pubkey local_funding_pubkey, remote_funding_pubkey;
+ struct basepoints localbase, remotebase;
+ struct pubkey *unknown = tal(ctx, struct pubkey);
+ struct channel_config *local_config = tal(ctx, struct channel_config);
+ struct channel_config *remote_config = tal(ctx, struct channel_config);
+ struct amount_msat to_local = fromwire_amount_msat_bounded(cursor, max);
+ struct amount_msat to_remote = fromwire_amount_msat_bounded(cursor, max);
+ UNUSED const u8 *funding_wscript;
+
+ /* Fixed values from BOLT #3 appendix. */
+ funding->txid = txid_from_hex("8984484a580b825b9972d7adb15050b3ab624ccd731946b3eeddb92f4e7ef6be");
+ funding->n = 0;
+
+ *funding_amount = AMOUNT_SAT(10000000);
+ remote_config->to_self_delay = 144;
+ local_config->dust_limit = AMOUNT_SAT(546);
+ remote_config->dust_limit = AMOUNT_SAT(546);
+ local_config->max_htlc_value_in_flight = AMOUNT_MSAT(-1ULL);
+ remote_config->max_htlc_value_in_flight = AMOUNT_MSAT(-1ULL);
+ local_config->max_dust_htlc_exposure_msat = AMOUNT_MSAT(-1ULL);
+ remote_config->max_dust_htlc_exposure_msat = AMOUNT_MSAT(-1ULL);
+ local_config->channel_reserve = AMOUNT_SAT(0);
+ remote_config->channel_reserve = AMOUNT_SAT(0);
+ local_config->htlc_minimum = AMOUNT_MSAT(0);
+ remote_config->htlc_minimum = AMOUNT_MSAT(0);
+ local_config->max_accepted_htlcs = 0xFFFF;
+ remote_config->max_accepted_htlcs = 0xFFFF;
+
+ remotebase.revocation = pubkey_from_hex("02466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27");
+ localbase.delayed_payment = pubkey_from_hex("023c72addb4fdf09af94f0c94d7fe92a386a7e70cf8a1d85916386bb2535c7b1b1");
+ localbase.payment = pubkey_from_hex("034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa");
+ remotebase.payment = pubkey_from_hex("032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991");
+ localbase.htlc = localbase.payment;
+ remotebase.htlc = remotebase.payment;
+ localbase.revocation = *unknown;
+ remotebase.delayed_payment = *unknown;
+
+ local_funding_pubkey = pubkey_from_hex("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb");
+ remote_funding_pubkey = pubkey_from_hex("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1");
+ funding_wscript = tal_hexdata(ctx, "5221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb2103"
+ "0e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae",
+ strlen("5221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb2103"
+ "0e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae"));
+ *local_per_commitment_point = pubkey_from_hex("025f7117a78150fe2ef97db7cfc83bd57b2e2c0d0dd25eaf467a4a1c2a45ce1486");
+
+ derive_channel_id(&cid, funding);
+
+ *lchannel = new_full_channel(ctx, &cid, funding, 0,
+ take(new_height_states(NULL, LOCAL, blockheight)),
+ 0, *funding_amount, to_local,
+ take(new_fee_states(NULL, LOCAL, &feerate_per_kw[LOCAL])),
+ local_config, remote_config,
+ &localbase, &remotebase,
+ &local_funding_pubkey, &remote_funding_pubkey,
+ take(channel_type_static_remotekey(NULL)), false, LOCAL);
+ *rchannel = new_full_channel(ctx, &cid, funding, 0,
+ take(new_height_states(NULL, REMOTE, blockheight)),
+ 0, *funding_amount, to_remote,
+ take(new_fee_states(NULL, REMOTE, &feerate_per_kw[REMOTE])),
+ remote_config, local_config,
+ &remotebase, &localbase,
+ &remote_funding_pubkey, &local_funding_pubkey,
+ take(channel_type_static_remotekey(NULL)), false, REMOTE);
+}
+
+void init(int *argc, char ***argv)
+{
+ /* Don't call this if we're in unit-test mode, as libfuzz.c does it */
+ if (!tmpctx)
+ common_setup("fuzzer");
+ chainparams = chainparams_for_network("bitcoin");
+ int devnull = open("/dev/null", O_WRONLY);
+ status_setup_sync(devnull);
+}
+
+struct pending_htlc {
+ enum side sender;
+ u64 id;
+ struct preimage preimage;
+};
+
+void run(const u8 *data, size_t size)
+{
+ struct pubkey local_per_commitment_point;
+ struct bitcoin_outpoint funding;
+ struct amount_sat funding_amount;
+ struct channel *lchannel, *rchannel;
+ const struct htlc **htlc_map;
+ const u8 *funding_wscript_alt;
+ int anchor;
+ struct pending_htlc *pending_htlcs = tal_arr(tmpctx, struct pending_htlc, 0);
+ u64 next_htlc_id = 0;
+ u32 current_blockheight = fromwire_u32(&data, &size);
+
+ init_channels(tmpctx, &data, &size, &local_per_commitment_point, &funding, &funding_amount,
+ ¤t_blockheight, &lchannel, &rchannel);
+
+ if (!lchannel || !rchannel)
+ goto cleanup;
+
+ while (size > 0) {
+ enum side commit_side = LOCAL;
+ int op = fromwire_u8(&data, &size) % 7;
+ switch (op) {
+ case 0: /* ADD_HTLC */
+ {
+ if (tal_count(pending_htlcs) > MAX_HTLCS)
+ break;
+
+ enum side sender = (fromwire_u8(&data, &size) % 2) ? REMOTE : LOCAL;
+ struct amount_msat msat = fromwire_amount_msat_bounded(&data, &size);
+ u32 cltv = current_blockheight + fromwire_u16(&data, &size);
+ struct pending_htlc p_htlc;
+
+ p_htlc.sender = sender;
+ p_htlc.id = next_htlc_id;
+ memset(&p_htlc.preimage, (int)p_htlc.id, sizeof(p_htlc.preimage));
+
+ if (add_htlc(lchannel, sender, p_htlc.id, &p_htlc.preimage, msat, cltv)) {
+ add_htlc(rchannel, sender, p_htlc.id, &p_htlc.preimage, msat, cltv);
+ tal_arr_expand(&pending_htlcs, p_htlc);
+ next_htlc_id++;
+ }
+ commit_side = sender;
+ break;
+ }
+ case 1: /* FULFILL_HTLC */
+ {
+ if (tal_count(pending_htlcs) == 0)
+ break;
+
+ size_t idx = fromwire_u64(&data, &size) % tal_count(pending_htlcs);
+ struct pending_htlc p_htlc = pending_htlcs[idx];
+
+ if (fromwire_u8(&data, &size) % 2) {
+ if (fulfill_htlc(lchannel, p_htlc.sender, p_htlc.id, &p_htlc.preimage)) {
+ fulfill_htlc(rchannel, p_htlc.sender, p_htlc.id, &p_htlc.preimage);
+ tal_arr_remove(&pending_htlcs, idx);
+ }
+ } else {
+ struct preimage preimage;
+ fromwire_preimage(&data, &size, &preimage);
+
+ fulfill_htlc(lchannel, fromwire_side(&data, &size),
+ fromwire_u64(&data, &size), &preimage);
+ fulfill_htlc(rchannel, fromwire_side(&data, &size),
+ fromwire_u64(&data, &size), &preimage);
+ }
+ commit_side = !p_htlc.sender;
+ break;
+ }
+ case 2: /* FAIL_HTLC */
+ {
+ if (tal_count(pending_htlcs) == 0)
+ break;
+
+ size_t idx = fromwire_u64(&data, &size) % tal_count(pending_htlcs);
+ struct pending_htlc p_htlc = pending_htlcs[idx];
+
+ if (fromwire_u8(&data, &size) % 2) {
+ if (fail_htlc(lchannel, p_htlc.sender, p_htlc.id)) {
+ fail_htlc(rchannel, p_htlc.sender, p_htlc.id);
+ tal_arr_remove(&pending_htlcs, idx);
+ }
+ }
+ else {
+ fail_htlc(lchannel, fromwire_side(&data, &size), fromwire_u64(&data, &size));
+ fail_htlc(rchannel, fromwire_side(&data, &size), fromwire_u64(&data, &size));
+ }
+ commit_side = !p_htlc.sender;
+ break;
+ }
+ case 3: /* UPDATE_FEE */
+ {
+ u32 feerate = fromwire_bounded_feerate(&data, &size);
+ if (update_feerate(lchannel, feerate))
+ assert(update_feerate(rchannel, feerate));
+ commit_side = rchannel->opener;
+ break;
+ }
+ case 4: /* UPDATE_BLOCKHEIGHT */
+ {
+ current_blockheight = fromwire_u32(&data, &size);
+ update_blockheight(lchannel, current_blockheight);
+ update_blockheight(rchannel, current_blockheight);
+ commit_side = rchannel->opener;
+ break;
+ }
+ case 5: /* EXCHANGE_COMMITS */
+ {
+ const struct htlc **changed_htlcs = tal_arr(tmpctx, const struct htlc *, 0);
+ exchange_commits(lchannel, commit_side, &changed_htlcs);
+ exchange_commits(rchannel, commit_side, &changed_htlcs);
+ break;
+ }
+ case 6: /* FUZZ_EXCHANGE_COMMITS */
+ {
+ const struct htlc **changed_htlcs = tal_arr(tmpctx, const struct htlc *, 0);
+ fuzz_exchange_commits(lchannel, &changed_htlcs, &data, &size);
+ fuzz_exchange_commits(rchannel, &changed_htlcs, &data, &size);
+ break;
+ }
+ }
+
+ /* Generate transactions to ensure no crashes from final state */
+ channel_txs(tmpctx, &funding, funding_amount, &htlc_map, NULL, &funding_wscript_alt,
+ lchannel, &local_per_commitment_point, 42, LOCAL, 0, 0, &anchor, NULL);
+ channel_txs(tmpctx, &funding, funding_amount, &htlc_map, NULL, &funding_wscript_alt,
+ rchannel, &local_per_commitment_point, 42, REMOTE, 0, 0, &anchor, NULL);
+ }
+
+cleanup:
+ clean_tmpctx();
+}
Why this scored 15/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.