diff --git a/Boss/Mod/XMoveFunds/Main.cpp b/Boss/Mod/XMoveFunds/Main.cpp index 3eadb02..62678fb 100644 --- a/Boss/Mod/XMoveFunds/Main.cpp +++ b/Boss/Mod/XMoveFunds/Main.cpp @@ -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 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 diff --git a/Ln/OnionError.cpp b/Ln/OnionError.cpp new file mode 100644 index 0000000..34cd441 --- /dev/null +++ b/Ln/OnionError.cpp @@ -0,0 +1,162 @@ +#include"Ln/OnionError.hpp" +#include"Util/Str.hpp" +#include + +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 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; +} + +}} diff --git a/Ln/OnionError.hpp b/Ln/OnionError.hpp new file mode 100644 index 0000000..35751b9 --- /dev/null +++ b/Ln/OnionError.hpp @@ -0,0 +1,92 @@ +#ifndef LN_ONIONERROR_HPP +#define LN_ONIONERROR_HPP + +#include +#include + +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) */ diff --git a/Makefile.am b/Makefile.am index ae530d4..09b7de8 100644 --- a/Makefile.am +++ b/Makefile.am @@ -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 \ diff --git a/tests/ln/test_onionerror.cpp b/tests/ln/test_onionerror.cpp new file mode 100644 index 0000000..50a8230 --- /dev/null +++ b/tests/ln/test_onionerror.cpp @@ -0,0 +1,224 @@ +#undef NDEBUG +#include"Ln/OnionError.hpp" +#include"Util/Str.hpp" +#include +#include +#include +#include + +namespace { + +/* Append a big-endian integer of `nbytes` to `b`. */ +void push_be( std::vector& 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 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(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 const& cu + , bool type_prefix + ) { + auto m = std::vector(); + push_be(m, failcode, 2); + m.insert(m.end(), header, 0); + auto full = std::vector(); + 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& 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; +}