Ln/OnionError: shared onion-failure parser; XMoveFunds adopts the hardened version
Some checks are pending
Code Base Sanity Check / tests (push) Waiting to run
Code Base Sanity Check / coverage (push) Waiting to run
Code Base Sanity Check / build-clang (push) Waiting to run

XMoveFunds/Main.cpp carried a duplicate of FundsMover/Attempter.cpp's
parse_chan_update that had drifted behind the PR320 deep-review
hardening: it lacked the fee_proportional_millionths > 1e6 reject, its
TLV bounds check (tpos + tlen > cu_size) could wrap on an
attacker-supplied bigsize length, and it accepted inbound-fee TLVs of
any length >= 8 instead of exactly 8.

Extract the parser into a new shared module, Ln/OnionError, carrying
the hardened semantics:

  - failcode_name(): BOLT 04 failcode -> grep-friendly name
    (previously Track-A-only).
  - ChanUpdate: the parsed channel_update policy subset askrene
    consumes, plus the bLIP-18 inbound-fee TLV fields, and an
    operator== that deliberately compares only the askrene-visible
    policy (the repeat-update detection use case).
  - parse_chan_update(): extract the embedded BOLT 07 channel_update
    from a sendpay 204 raw_message, with the prop clamp doubling as
    an overflow guarantee for callers' ceil(amt * prop / 1e6) math.

XMoveFunds switches to the shared module via using-declarations, so
call sites are unchanged; its three 204 log lines now print the
failcode name next to the hex.  Also correct a stale comment above
failure_summary() claiming the channel_update-refresh branch is
skipped -- it has existed since the FEE_INSUFFICIENT dissection work;
only the in-call retry is (deliberately) absent.

FundsMover/Attempter.cpp intentionally keeps its own copy: Track A is
being treated as done, and switching it to Ln/OnionError is deferred
to the next time Track A is touched for its own reasons.

New unit test tests/ln/test_onionerror covers the onion wire format
for all carrying failcodes, both type-prefix forms, the prop clamp
boundary (1e6 accepted, 1e6+1 rejected), signed inbound-fee values,
exact-8 TLV length enforcement, and the bigsize-wrap attack that the
old Track B bounds check would have mishandled.  86/86 tests pass.
This commit is contained in:
Ken Sedgwick 2026-07-06 15:55:02 -07:00
parent 4c72671349
commit fe220493df
No known key found for this signature in database
GPG key ID: DBD2AF0849D711A9
5 changed files with 505 additions and 173 deletions

View file

@ -30,6 +30,7 @@
#include"Ln/Amount.hpp"
#include"Ln/CommandId.hpp"
#include"Ln/NodeId.hpp"
#include"Ln/OnionError.hpp"
#include"Ln/Preimage.hpp"
#include"Ln/Scid.hpp"
#include"S/Bus.hpp"
@ -58,167 +59,13 @@ namespace {
* (e.g. Dowser, MoveFundsCommand). */
constexpr int RPC_INVALID_PARAMS = -32602;
/* Parsed channel_update fields fed back into askrene via
* AskreneLayer::update_channel after a sendpay 204 with an
* onion-error failcode that carries a channel_update payload.
* Mirrors the subset of BOLT 07 channel_update fields askrene-
* update-channel accepts.
*
* Duplicated from FundsMover/Attempter.cpp for now -- both
* sites parse the same wire format with the same field set.
* Pulling the parser into a shared module (Util/, Ln/, or a
* new Boss/Mod/ChanUpdate) is a separate cleanup tracked
* apart from xrebalance work. */
struct ChanUpdate {
bool enabled;
std::uint16_t cltv_expiry_delta;
std::uint64_t htlc_minimum_msat;
std::uint32_t fee_base_msat;
std::uint32_t fee_proportional_millionths;
std::uint64_t htlc_maximum_msat;
/* bLIP-18 inbound fees (TLV 55555), signed. has_inbound_fee
* is false when the channel_update carries no such TLV. */
bool has_inbound_fee = false;
std::int32_t inbound_fee_base_msat = 0;
std::int32_t inbound_fee_proportional_millionths = 0;
};
/* Read a big-endian unsigned integer of 1..8 bytes from `data`
* starting at `offset`. Caller ensures the read is in-bounds.
*/
std::uint64_t read_be( std::uint8_t const* data
, std::size_t offset
, std::size_t nbytes
) {
auto v = std::uint64_t(0);
for (auto i = std::size_t(0); i < nbytes; ++i)
v = (v << 8) | std::uint64_t(data[offset + i]);
return v;
}
/* Read a BOLT 01 BigSize at `pos` in `data` (size `size`), advancing
* `pos` past it. Returns false if truncated. */
bool read_bigsize( std::uint8_t const* data
, std::size_t size
, std::size_t& pos
, std::uint64_t& out
) {
if (pos >= size)
return false;
auto first = data[pos];
auto nbytes = std::size_t( first < 0xfd ? 0
: first == 0xfd ? 2
: first == 0xfe ? 4
: 8 );
if (nbytes == 0) {
out = first;
pos += 1;
return true;
}
if (pos + 1 + nbytes > size)
return false;
out = read_be(data, pos + 1, nbytes);
pos += 1 + nbytes;
return true;
}
/* Parse a BOLT 04 onion failure payload (the `raw_message` hex
* from sendpay_failure data) and extract the embedded BOLT 07
* channel_update fields. Returns true on success and writes the
* parsed values into `out`; returns false if the hex is malformed,
* the failcode does not carry a channel_update, or the payload is
* truncated.
*
* Wire layout of the onion failure for the relevant failcodes:
*
* 2 failcode
* X variable per-failcode header:
* 0x1007 / 0x100e: 0 bytes
* 0x100b / 0x100c (amount): 8 bytes htlc_msat
* 0x100d (cltv): 4 bytes cltv_expiry
* 2 channel_update length (big-endian)
* N channel_update bytes
*
* channel_update wire layout (BOLT 07), 128 bytes after the
* optional 2-byte 0x0102 type prefix. We only need the policy
* fields (offset 109 onwards in the body), so we skip past
* signature (64), chain_hash (32), short_channel_id (8),
* timestamp (4), and message_flags (1). The 2-byte type prefix
* is present in CLN-issued channel_updates and absent in
* LND-pre-v0.18 ones; detect by sniffing the first two bytes.
*/
bool parse_chan_update( std::string const& raw_message_hex
, ChanUpdate& out
) {
std::vector<std::uint8_t> bytes;
try {
bytes = Util::Str::hexread(raw_message_hex);
} catch (std::exception const&) {
return false;
}
if (bytes.size() < 4)
return false;
auto failcode = std::uint16_t((bytes[0] << 8) | bytes[1]);
auto header = std::size_t(0);
switch (failcode) {
case 0x1007: case 0x100e: header = 0; break;
case 0x100b: case 0x100c: header = 8; break;
case 0x100d: header = 4; break;
default: return false;
}
auto pos = std::size_t(2) + header;
if (bytes.size() < pos + 2)
return false;
auto cu_len = std::size_t((bytes[pos] << 8) | bytes[pos + 1]);
pos += 2;
if (cu_len == 0 || bytes.size() < pos + cu_len)
return false;
auto cu = bytes.data() + pos;
auto cu_size = cu_len;
/* Skip the optional 2-byte type prefix 0x0102 if present. */
if (cu_size >= 2 && cu[0] == 0x01 && cu[1] == 0x02) {
cu += 2;
cu_size -= 2;
}
if (cu_size < 136)
return false;
auto channel_flags = cu[109];
out.enabled = !(channel_flags & 0x02);
out.cltv_expiry_delta = std::uint16_t(read_be(cu, 110, 2));
out.htlc_minimum_msat = read_be(cu, 112, 8);
out.fee_base_msat = std::uint32_t(read_be(cu, 120, 4));
out.fee_proportional_millionths = std::uint32_t(read_be(cu, 124, 4));
out.htlc_maximum_msat = read_be(cu, 128, 8);
/* Scan the trailing TLV stream for bLIP-18 inbound fees
* (type 55555): value is [i32 base][i32 prop], both signed. */
out.has_inbound_fee = false;
out.inbound_fee_base_msat = 0;
out.inbound_fee_proportional_millionths = 0;
auto tpos = std::size_t(136);
while (tpos < cu_size) {
auto ttype = std::uint64_t(0);
auto tlen = std::uint64_t(0);
if (!read_bigsize(cu, cu_size, tpos, ttype))
break;
if (!read_bigsize(cu, cu_size, tpos, tlen))
break;
if (tpos + tlen > cu_size)
break;
if (ttype == 55555 && tlen >= 8) {
out.has_inbound_fee = true;
out.inbound_fee_base_msat =
std::int32_t(std::uint32_t(read_be(cu, tpos, 4)));
out.inbound_fee_proportional_millionths =
std::int32_t(std::uint32_t(read_be(cu, tpos + 4, 4)));
}
tpos += tlen;
}
return true;
}
/* Onion-error interpretation (ChanUpdate, parse_chan_update,
* failcode_name) lives in Ln/OnionError. FundsMover/Attempter.cpp
* still carries its own copy of the parser; switching it to the
* shared module is deferred until Track A is next touched. */
using Ln::OnionError::ChanUpdate;
using Ln::OnionError::parse_chan_update;
using Ln::OnionError::failcode_name;
/* Decode either a single scid string or an array of scid strings
* from a JSON value into a vector. Throws on type/format error
@ -1130,13 +977,12 @@ private:
* lookup fails -- a strictly safer signal than no
* feedback at all.
*
* Mirrors the simpler half of FundsMover/Attempter.cpp's
* 204 handling; we deliberately skip the
* channel_update-refresh branch (parse_chan_update +
* update_channel with policy fields) for now -- the manual
* xmovefunds primitive does not yet retry, so the inform-
* constrained path alone is sufficient to make the NEXT
* manual invocation pick a different route. */
* Mirrors FundsMover/Attempter.cpp's 204 handling, including
* the channel_update-refresh branch (parse_chan_update +
* Msg::AskreneChannelUpdate with policy fields) -- but unlike
* the Attempter there is no retry within one invocation; the
* refresh lands in the AskreneUpdates store and benefits the
* NEXT invocation via its projected private layer. */
/* Concise one-line failure summary for the response's errors[]
* (and thus the XRebalancer "reason:" log line, so a single
* grep XRebalancer shows how close the part got). For a 204 it
@ -1171,12 +1017,14 @@ private:
(eidx < route_hops)
? (route_hops - eidx)
: std::size_t(0);
auto fc = std::uint16_t(double(
data["failcode"]));
auto os = std::ostringstream();
os << "204 from_target=" << from_target
<< " failcode=0x" << std::hex
<< std::uint16_t(double(
data["failcode"]))
<< std::dec << " node="
<< " failcode=0x" << std::hex << fc
<< std::dec
<< "(" << failcode_name(fc) << ")"
<< " node="
<< std::string(data["erring_node"]);
return os.str();
}
@ -1248,7 +1096,9 @@ private:
: std::size_t(0);
auto sum = std::ostringstream();
sum << "XMoveFunds: 204 failcode=0x" << std::hex << fail
<< std::dec << " erring=" << echan_str << "/" << edir
<< std::dec
<< "(" << failcode_name(fail) << ")"
<< " erring=" << echan_str << "/" << edir
<< " node=" << std::string(enode)
<< " from_target=" << from_target
<< " alloc_fee="
@ -1287,6 +1137,7 @@ private:
auto os = std::ostringstream();
os << "XMoveFunds: 204 picture failcode=0x"
<< std::hex << fail << std::dec
<< "(" << failcode_name(fail) << ")"
<< " erring_index=" << eidx
<< " erring_channel=" << echan_str
<< "/" << edir

162
Ln/OnionError.cpp Normal file
View file

@ -0,0 +1,162 @@
#include"Ln/OnionError.hpp"
#include"Util/Str.hpp"
#include<vector>
namespace {
/* Read a big-endian unsigned integer of 1..8 bytes from `data`
* starting at `offset`. Caller ensures the read is in-bounds.
*/
std::uint64_t read_be( std::uint8_t const* data
, std::size_t offset
, std::size_t nbytes
) {
auto v = std::uint64_t(0);
for (auto i = std::size_t(0); i < nbytes; ++i)
v = (v << 8) | std::uint64_t(data[offset + i]);
return v;
}
/* Read a BOLT 01 BigSize at `pos` in `data` (size `size`), advancing
* `pos` past it. Returns false if truncated. */
bool read_bigsize( std::uint8_t const* data
, std::size_t size
, std::size_t& pos
, std::uint64_t& out
) {
if (pos >= size)
return false;
auto first = data[pos];
auto nbytes = std::size_t( first < 0xfd ? 0
: first == 0xfd ? 2
: first == 0xfe ? 4
: 8 );
if (nbytes == 0) {
out = first;
pos += 1;
return true;
}
if (pos + 1 + nbytes > size)
return false;
out = read_be(data, pos + 1, nbytes);
pos += 1 + nbytes;
return true;
}
}
namespace Ln { namespace OnionError {
char const* failcode_name(std::uint16_t f) {
switch (f) {
case 0x1007: return "TEMPORARY_CHANNEL_FAILURE";
case 0x100b: return "AMOUNT_BELOW_MINIMUM";
case 0x100c: return "FEE_INSUFFICIENT";
case 0x100d: return "INCORRECT_CLTV_EXPIRY";
case 0x100e: return "EXPIRY_TOO_SOON";
case 0x1014: return "CHANNEL_DISABLED";
case 0x2002: return "TEMPORARY_NODE_FAILURE";
case 0x4008: return "PERMANENT_CHANNEL_FAILURE";
case 0x400a: return "UNKNOWN_NEXT_PEER";
case 0x4010: return "REQUIRED_CHANNEL_FEATURE_MISSING";
case 0x6002: return "PERMANENT_NODE_FAILURE";
default: return "UNKNOWN";
}
}
bool parse_chan_update( std::string const& raw_message_hex
, ChanUpdate& out
) {
std::vector<std::uint8_t> bytes;
try {
bytes = Util::Str::hexread(raw_message_hex);
} catch (std::exception const&) {
return false;
}
if (bytes.size() < 4)
return false;
auto failcode = std::uint16_t((bytes[0] << 8) | bytes[1]);
auto header = std::size_t(0);
switch (failcode) {
case 0x1007: case 0x100e: header = 0; break;
case 0x100b: case 0x100c: header = 8; break;
case 0x100d: header = 4; break;
default: return false;
}
auto pos = std::size_t(2) + header;
if (bytes.size() < pos + 2)
return false;
auto cu_len = std::size_t((bytes[pos] << 8) | bytes[pos + 1]);
pos += 2;
if (cu_len == 0 || bytes.size() < pos + cu_len)
return false;
auto cu = bytes.data() + pos;
auto cu_size = cu_len;
/* Skip the optional 2-byte type prefix 0x0102 if present. */
if (cu_size >= 2 && cu[0] == 0x01 && cu[1] == 0x02) {
cu += 2;
cu_size -= 2;
}
/* Fixed-layout body from offset 0 of the post-prefix
* channel_update: 64 sig + 32 chain_hash + 8
* short_channel_id + 4 timestamp + 1 message_flags + 1
* channel_flags + 2 cltv_expiry_delta + 8 htlc_minimum_msat
* + 4 fee_base_msat + 4 fee_proportional_millionths + 8
* htlc_maximum_msat = 136.
*/
if (cu_size < 136)
return false;
auto channel_flags = cu[109];
out.enabled = !(channel_flags & 0x02);
out.cltv_expiry_delta = std::uint16_t(read_be(cu, 110, 2));
out.htlc_minimum_msat = read_be(cu, 112, 8);
out.fee_base_msat = std::uint32_t(read_be(cu, 120, 4));
out.fee_proportional_millionths = std::uint32_t(read_be(cu, 124, 4));
out.htlc_maximum_msat = read_be(cu, 128, 8);
/* Reject absurd signed policies rather than propagating them.
* A proportional fee above 100% is never a policy we would
* pay, and bounding it here is the overflow guarantee the
* header doc promises callers. Failing the parse routes the
* failure into the callers' max_msat=0 hard-exclusion
* fallback -- the right response to a forwarder signing
* garbage.
*/
if (out.fee_proportional_millionths > 1000000)
return false;
/* Scan the trailing TLV stream for bLIP-18 inbound fees
* (type 55555): value is [i32 base][i32 prop], both signed. */
out.has_inbound_fee = false;
out.inbound_fee_base_msat = 0;
out.inbound_fee_proportional_millionths = 0;
auto tpos = std::size_t(136);
while (tpos < cu_size) {
auto ttype = std::uint64_t(0);
auto tlen = std::uint64_t(0);
if (!read_bigsize(cu, cu_size, tpos, ttype))
break;
if (!read_bigsize(cu, cu_size, tpos, tlen))
break;
/* Overflow-safe: tpos <= cu_size (guaranteed by
* read_bigsize) so cu_size - tpos cannot underflow,
* whereas tpos + tlen can wrap for an attacker-supplied
* tlen and slip past a `> cu_size` check. */
if (tlen > std::uint64_t(cu_size - tpos))
break;
if (ttype == 55555 && tlen == 8) {
out.has_inbound_fee = true;
out.inbound_fee_base_msat =
std::int32_t(std::uint32_t(read_be(cu, tpos, 4)));
out.inbound_fee_proportional_millionths =
std::int32_t(std::uint32_t(read_be(cu, tpos + 4, 4)));
}
tpos += tlen;
}
return true;
}
}}

92
Ln/OnionError.hpp Normal file
View file

@ -0,0 +1,92 @@
#ifndef LN_ONIONERROR_HPP
#define LN_ONIONERROR_HPP
#include<cstdint>
#include<string>
namespace Ln { namespace OnionError {
/** char const* Ln::OnionError::failcode_name(failcode)
*
* @brief BOLT 04 onion failure code -> short human-readable
* name. Used in sendpay-204 logs so the failcode can be
* grepped by name (FEE_INSUFFICIENT etc.) rather than only
* the hex. Returns "UNKNOWN" for codes not in the table.
*/
char const* failcode_name(std::uint16_t failcode);
/** struct Ln::OnionError::ChanUpdate
*
* @brief Parsed BOLT 07 channel_update policy fields, as
* extracted from the payload a BOLT 04 onion failure embeds.
* Mirrors the subset of channel_update fields that
* askrene-update-channel accepts, plus the bLIP-18 inbound-fee
* TLV.
*/
struct ChanUpdate {
bool enabled;
std::uint16_t cltv_expiry_delta;
std::uint64_t htlc_minimum_msat;
std::uint32_t fee_base_msat;
std::uint32_t fee_proportional_millionths;
std::uint64_t htlc_maximum_msat;
/* bLIP-18 inbound fees (TLV 55555), signed. has_inbound_fee
* is false when the channel_update carries no such TLV. */
bool has_inbound_fee = false;
std::int32_t inbound_fee_base_msat = 0;
std::int32_t inbound_fee_proportional_millionths = 0;
/* Compares only the fields askrene consumes, deliberately
* excluding the inbound-fee TLV: the use case is detecting a
* forwarder that returns the identical signed policy we
* already applied (enforcement diverging from gossip), and
* askrene never prices inbound fees, so a TLV-only change
* would not alter any route we build. */
bool operator==(ChanUpdate const& o) const {
return enabled == o.enabled
&& cltv_expiry_delta == o.cltv_expiry_delta
&& htlc_minimum_msat == o.htlc_minimum_msat
&& fee_base_msat == o.fee_base_msat
&& fee_proportional_millionths == o.fee_proportional_millionths
&& htlc_maximum_msat == o.htlc_maximum_msat;
}
};
/** bool Ln::OnionError::parse_chan_update(raw_message_hex, out)
*
* @brief Parse a BOLT 04 onion failure payload (the
* `raw_message` hex from sendpay_failure data) and extract the
* embedded BOLT 07 channel_update fields. Returns true on
* success and writes the parsed values into `out`; returns
* false if the hex is malformed, the failcode does not carry a
* channel_update, the payload is truncated, or the update
* carries an absurd policy (proportional fee above 100%).
*
* The proportional-fee bound doubles as an overflow guarantee
* for callers: with fee_proportional_millionths <= 1e6, a
* ceil(amount * prop / 1e6) computed in uint64 cannot wrap for
* any Lightning-plausible amount, whereas a forwarder-signed
* 0xFFFFFFFF would.
*
* Wire layout of the onion failure for the relevant failcodes:
*
* 2 failcode
* X variable per-failcode header:
* 0x1007 / 0x100e: 0 bytes
* 0x100b / 0x100c (amount): 8 bytes htlc_msat
* 0x100d (cltv): 4 bytes cltv_expiry
* 2 channel_update length (big-endian)
* N channel_update bytes
*
* The channel_update's 2-byte type prefix 0x0102 is present in
* CLN-issued channel_updates and absent in LND-pre-v0.18 ones;
* both forms are accepted (detected by sniffing the first two
* bytes).
*/
bool parse_chan_update( std::string const& raw_message_hex
, ChanUpdate& out
);
}}
#endif /* !defined(LN_ONIONERROR_HPP) */

View file

@ -485,6 +485,8 @@ libclboss_la_SOURCES = \
Ln/HtlcAccepted.hpp \
Ln/NodeId.cpp \
Ln/NodeId.hpp \
Ln/OnionError.cpp \
Ln/OnionError.hpp \
Ln/Preimage.cpp \
Ln/Preimage.hpp \
Ln/Scid.cpp \
@ -690,6 +692,7 @@ TESTS = \
tests/ln/test_commandid \
tests/ln/test_htlcaccepted \
tests/ln/test_nodeid \
tests/ln/test_onionerror \
tests/ln/test_scid \
tests/net/test_ipaddr \
tests/net/test_ipaddroronion \

View file

@ -0,0 +1,224 @@
#undef NDEBUG
#include"Ln/OnionError.hpp"
#include"Util/Str.hpp"
#include<assert.h>
#include<cstdint>
#include<string>
#include<vector>
namespace {
/* Append a big-endian integer of `nbytes` to `b`. */
void push_be( std::vector<std::uint8_t>& b
, std::uint64_t v
, std::size_t nbytes
) {
for (auto i = std::size_t(0); i < nbytes; ++i)
b.push_back(std::uint8_t((v >> (8 * (nbytes - 1 - i))) & 0xff));
}
/* Build a 136-byte BOLT 07 channel_update body (no type prefix):
* 64 sig + 32 chain_hash + 8 scid + 4 timestamp + 1 message_flags
* + 1 channel_flags + 2 cltv + 8 htlc_min + 4 base + 4 prop
* + 8 htlc_max, sig..timestamp left zeroed. */
std::vector<std::uint8_t> make_cu_body( bool disabled
, std::uint16_t cltv
, std::uint64_t htlc_min
, std::uint32_t base
, std::uint32_t prop
, std::uint64_t htlc_max
) {
auto b = std::vector<std::uint8_t>(110, 0);
b[109] = disabled ? 0x02 : 0x00;
push_be(b, cltv, 2);
push_be(b, htlc_min, 8);
push_be(b, base, 4);
push_be(b, prop, 4);
push_be(b, htlc_max, 8);
assert(b.size() == 136);
return b;
}
/* Wrap a channel_update in a BOLT 04 onion failure payload:
* failcode + `header` zero bytes + 2-byte length + optional
* 0x0102 type prefix + the update. */
std::string make_onion_hex( std::uint16_t failcode
, std::size_t header
, std::vector<std::uint8_t> const& cu
, bool type_prefix
) {
auto m = std::vector<std::uint8_t>();
push_be(m, failcode, 2);
m.insert(m.end(), header, 0);
auto full = std::vector<std::uint8_t>();
if (type_prefix) {
full.push_back(0x01);
full.push_back(0x02);
}
full.insert(full.end(), cu.begin(), cu.end());
push_be(m, full.size(), 2);
m.insert(m.end(), full.begin(), full.end());
return Util::Str::hexdump(m.data(), m.size());
}
/* Append a bLIP-18 inbound-fee TLV (type 55555) with the given
* length byte and [i32 base][i32 prop] value (value always 8
* bytes; a `len` other than 8 makes the TLV malformed-for-us
* on purpose). */
void push_inbound_tlv( std::vector<std::uint8_t>& cu
, std::uint8_t len
, std::int32_t base
, std::int32_t prop
) {
/* type 55555 = 0xd903 needs the 0xfd bigsize form. */
cu.push_back(0xfd);
push_be(cu, 55555, 2);
cu.push_back(len);
push_be(cu, std::uint32_t(base), 4);
push_be(cu, std::uint32_t(prop), 4);
for (auto i = std::size_t(8); i < std::size_t(len); ++i)
cu.push_back(0);
}
}
int main() {
using Ln::OnionError::ChanUpdate;
using Ln::OnionError::parse_chan_update;
using Ln::OnionError::failcode_name;
/* Happy path: 0x100c (8-byte header), CLN-style 0x0102 type
* prefix, no TLVs. */
{
auto cu_bytes = make_cu_body(false, 144, 1000, 1234, 567,
1000000000);
auto cu = ChanUpdate();
assert(parse_chan_update(
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
assert(cu.enabled);
assert(cu.cltv_expiry_delta == 144);
assert(cu.htlc_minimum_msat == 1000);
assert(cu.fee_base_msat == 1234);
assert(cu.fee_proportional_millionths == 567);
assert(cu.htlc_maximum_msat == 1000000000);
assert(!cu.has_inbound_fee);
}
/* Same update without the type prefix (LND-pre-v0.18 style),
* and via a 0x100d failure (4-byte header). */
{
auto cu_bytes = make_cu_body(true, 40, 1, 0, 100, 21000000);
auto cu = ChanUpdate();
assert(parse_chan_update(
make_onion_hex(0x100d, 4, cu_bytes, false), cu));
assert(!cu.enabled);
assert(cu.cltv_expiry_delta == 40);
assert(cu.fee_proportional_millionths == 100);
}
/* Proportional fee above 100% is rejected: a forwarder-signed
* absurd policy must fail the parse (and thus route callers
* into their hard-exclusion fallback), not propagate. */
{
auto cu_bytes = make_cu_body(false, 144, 0, 0, 1000001, 1);
auto cu = ChanUpdate();
assert(!parse_chan_update(
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
/* Exactly 100% is still accepted. */
cu_bytes = make_cu_body(false, 144, 0, 0, 1000000, 1);
assert(parse_chan_update(
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
}
/* bLIP-18 inbound-fee TLV, including negative (discount)
* base: values are signed i32. */
{
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
push_inbound_tlv(cu_bytes, 8, -1000, 250);
auto cu = ChanUpdate();
assert(parse_chan_update(
make_onion_hex(0x100b, 8, cu_bytes, true), cu));
assert(cu.has_inbound_fee);
assert(cu.inbound_fee_base_msat == -1000);
assert(cu.inbound_fee_proportional_millionths == 250);
}
/* TLV length must be exactly 8: a 9-byte 55555 TLV is not
* treated as an inbound fee (but does not fail the parse). */
{
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
push_inbound_tlv(cu_bytes, 9, 1000, 250);
auto cu = ChanUpdate();
assert(parse_chan_update(
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
assert(!cu.has_inbound_fee);
}
/* Malicious TLV length that would wrap a naive
* `tpos + tlen > cu_size` bounds check: bigsize 0xff with
* 0xFFFFFFFFFFFFFFFF. The parse must neither crash nor
* accept the TLV. */
{
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
cu_bytes.push_back(0xfd);
push_be(cu_bytes, 55555, 2);
cu_bytes.push_back(0xff);
push_be(cu_bytes, 0xFFFFFFFFFFFFFFFFull, 8);
/* 8 in-bounds bytes a wrapped check would misread. */
push_be(cu_bytes, 0x1122334455667788ull, 8);
auto cu = ChanUpdate();
assert(parse_chan_update(
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
assert(!cu.has_inbound_fee);
}
/* Failcodes that carry no channel_update are rejected. */
{
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
auto cu = ChanUpdate();
assert(!parse_chan_update(
make_onion_hex(0x2002, 0, cu_bytes, true), cu));
}
/* Truncated payloads and garbage hex are rejected. */
{
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
auto hex = make_onion_hex(0x100c, 8, cu_bytes, true);
auto cu = ChanUpdate();
assert(!parse_chan_update(hex.substr(0, hex.size() - 40), cu));
assert(!parse_chan_update("zznothexzz", cu));
assert(!parse_chan_update("", cu));
/* Zero-length channel_update. */
assert(!parse_chan_update("100c00000000000000000000", cu));
}
/* operator== compares the askrene-visible policy only; a
* difference confined to the inbound-fee TLV still compares
* equal (repeat-update detection semantics). */
{
auto mk = [](std::uint32_t prop) {
auto cu = ChanUpdate();
cu.enabled = true;
cu.cltv_expiry_delta = 144;
cu.htlc_minimum_msat = 1000;
cu.fee_base_msat = 0;
cu.fee_proportional_millionths = prop;
cu.htlc_maximum_msat = 1;
return cu;
};
auto a = mk(100);
auto b = mk(100);
assert(a == b);
b.has_inbound_fee = true;
b.inbound_fee_base_msat = 5000;
assert(a == b);
auto c = mk(101);
assert(!(a == c));
}
assert(std::string(failcode_name(0x100c)) == "FEE_INSUFFICIENT");
assert(std::string(failcode_name(0x100d)) == "INCORRECT_CLTV_EXPIRY");
assert(std::string(failcode_name(0xdead)) == "UNKNOWN");
return 0;
}