Skip to content

smite: validate negotiated features and channel types in accept_channel oracle - #209

Open
NishantBansal2003 wants to merge 4 commits into
lnfuzz:masterfrom
NishantBansal2003:accept-chan-feature-oracle
Open

NishantBansal2003 wants to merge 4 commits into
lnfuzz:masterfrom
NishantBansal2003:accept-chan-feature-oracle

Conversation

@NishantBansal2003

@NishantBansal2003 NishantBansal2003 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Depends-on: #192
Depends-on: #186 (added some changes to it, which will be removed once that PR is finalized)
Opening this as a draft since it depends on two PRs. Otherwise, the changes are ready for review.

I tried to add all the definitive checks for open_channel and accept_channel mentioned in the BOLT 2, including ones that aren't currently possible, such as option_dual_fund, since we haven't negotiated it. I think having parity with the BOLTs is good, especially for the future when we extend smite to support more protocols

Some more missing oracles that I will add in follow-up PR are:

open_channel

  • chain_hash != regtest
  • channel_reserve_satoshis >= funding_satoshis
  • dust_limit_satoshis > 10_000 sat
  • to_self_delay > 2016
  • feerate_per_kw < 253 for non 0FC channels

accept_channel

  • dust_limit_satoshis > 10_000 sat
  • htlc_minimum_msat > max_htlc_value_in_flight_msat
  • htlc_minimum_msat > open_channel.funding_satoshis
  • to_self_delay < 144
  • max_accepted_htlcs == 0
  • pubkey match any pubkeys from open_channel and accept_channel
  • per_commitment_point should not be reused

Known violations found by this that need to be suppressed are:

@ekzyis

ekzyis commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Depends-on: #186 (added some changes to it, which will be removed once that PR is finalized)

Oh, I like how you added is_acceptable_shutdown_script. Would adding this to #186 resolve #186 (comment) for you?

@NishantBansal2003

Copy link
Copy Markdown
Contributor Author

Oh, I like how you added is_acceptable_shutdown_script. Would adding this to #186 resolve #186 (comment) for you?

Yeah, I just added that function to keep things simple, but this could be done via an enum Side as well. I thought this was simple enough to get the work done

Comment thread smite/src/bolt/features.rs Outdated
/// Returns whether every bit set here is supported by `other`, where the
/// feature's required (even) or optional (odd) bit both count as support.
#[must_use]
pub fn is_supported(&self, other: &Features) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe is_compatible would be a better name?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to is_supported_by, I think that would be clearer when checking whether something is a subset of a feature

Comment thread smite/src/oracles/accept_channel.rs Outdated
}

/// Returns the maximum funding amount allowed by the negotiated features.
pub fn max_funding_satoshis(negotiated_features: &Features) -> u64 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn max_funding_satoshis(negotiated_features: &Features) -> u64 {
fn max_funding_satoshis(negotiated_features: &Features) -> u64 {

Comment thread smite/src/oracles/accept_channel.rs Outdated
}

/// Returns the maximum number of inbound HTLCs allowed by the channel type.
pub fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 {
fn max_accepted_htlcs_limit(channel_type: &Features) -> u16 {

Comment on lines +89 to +93
// Since we echo the same features the target sent, but strip both
// required and optional bits to exercise only the single funded
// flow and avoid unrelated noise, negotiated features are just the
// features we sent in our init.
negotiated_features: Features::from(our_init.features),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be a bitwise-or of our_init.globalfeatures and our_init.features?

  • MUST combine (bitwise OR) the two feature bitmaps into one logical features map.

https://github.com/lightning/bolts/blob/152897261850d93c4f4597f39cf22d7d22d6ede6/01-messaging.md?plain=1#L318

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec-wise, yes, but I think globalfeatures is a legacy feature. From what I can see, the spec doesn't require the two vectors to be disjoint, and in practice, every target duplicates rather than partitions them: LDK sends a masked copy of bits 0-13 from its own features, CLN and LND send only var_onion/static_remotekey, which they also set in features, and Eclair sends it empty. globalfeatures is a subset of features for all four, so there shouldn't be any issues if we just use features instead of OR-ing them

see:

We strip certain feature bits during setup to exercise only the single
funded flow, so the features stored here are what both sides have
agreed to continue with, the negotiated feature set, not just the
target's advertised features.

This prepares for oracle validation that will use negotiated features to
validate field constraints. If the target didn't disconnect after our init,
that confirms it also conforms to our negotiated features, not its original
advertised feature set.

Signed-off-by: Nishant Bansal <[email protected]>
This is useful when comparing features in message fields
against negotiated features. For eg., comparing channel_type
in open_channel and accept_channel to ensure they match the
features negotiated during setup.

Signed-off-by: Nishant Bansal <[email protected]>
@NishantBansal2003
NishantBansal2003 force-pushed the accept-chan-feature-oracle branch from 05bce1c to adece46 Compare September 9, 2026 19:48
Comment on lines +139 to +140
/// Features negotiated between the target node and Smite.
pub negotiated_features: Features,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What confused me a little here is that since these are already negotiated features, I'm not sure what would be the difference between an odd vs even bit for a feature.

is_supported_by checks both bits, so for the rest of the code, it doesn't matter which bit of a feature you set afaict. If I could tell correctly, I think this would be nice to clarify:

Suggested change
/// Features negotiated between the target node and Smite.
pub negotiated_features: Features,
/// Features negotiated between the target node and Smite. A feature may
/// appear as an even or odd bit; the distinction carries no meaning here.
pub negotiated_features: Features,

// be supported.
if !negotiated_features.supports_feature(Features::OPTION_CHANNEL_TYPE) {
return Err("option_channel_type is not supported".to_string());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought ASSUMED and "can safely be ignored" in BOLT-9 means that the peer doesn't need to set any bit for this feature, but we simply assume it's enabled. So I'm not sure if this check here makes sense.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. I don't think it's a spec violation to omit the feature bit -- the idea behind making it ASSUMED was to allow reusing the feature bit eventually with a different meaning.

Comment on lines +142 to +146
negotiated_features: Features::from_bits(&[
Features::OPTION_STATIC_REMOTEKEY,
Features::OPTION_ANCHORS,
Features::OPTION_CHANNEL_TYPE,
]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my other comment about removing the check for OPTION_CHANNEL_TYPE: I interpret "can be safely ignored" as "don't check if the feature is enabled; assume it is", so a peer who didn't set this bit in init isn't wrong.

I think it could make sense to distinguish which bits we set here because we assume them to be negotiated (OPTION_STATIC_REMOTEKEY and OPTION_CHANNEL_TYPE) vs. for other reasons (OPTION_ANCHORS). So instead of adding assumed feature bits manually here, we could have a list of assumed features, and then include them here for our sample ProgramContext in the tests and in SnapshotSetup during fuzzing.

However, this is related to the question of whether we even need to include assumed bits in our context in the first place if we can assume them to be set, so all checks based on these features are unconditional.

We could also postpone this discussion for an InitOracle, because it would be more concerned with the meaning of bits, like that only one bit should be set for a feature.

WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will remove the check for OPTION_CHANNEL_TYPE being present in the negotiated features, so I think we can safely remove OPTION_CHANNEL_TYPE from the negotiated features as well

}

// Check the channel reserve covers the dust limit.
if accept_channel.dust_limit_satoshis > accept_channel.channel_reserve_satoshis {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to this PR: Shouldn't this be this?

-if accept_channel.dust_limit_satoshis > accept_channel.channel_reserve_satoshis
+if accept_channel.dust_limit_satoshis > open_channel.channel_reserve_satoshis

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ekzyis ekzyis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some more nits, but LGTM! Will probably ACK tomorrow

}

// Check that option_scid_alias is only negotiated for private channels.
let announce_channel = (open_channel.channel_flags & 1) == 1;

@ekzyis ekzyis Sep 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could define a constant ANNOUNCE_CHANNEL_FLAG so it's clear why we use 1 here without looking up the spec. It's kind of easy to miss in the spec:

https://github.com/lightning/bolts/blob/152897261850d93c4f4597f39cf22d7d22d6ede6/02-peer-protocol.md?plain=1#L769-L772

) -> Result<(), String> {
// Check that option_dual_fund has not been negotiated.
if negotiated_features.supports_feature(Features::OPTION_DUAL_FUND) {
return Err("option_dual_fund has been negotiated".to_string());

@ekzyis ekzyis Sep 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We use supports_feature here and at line 138, but the error message uses "negotiated" vs "supported". I think we should use one verb consistently in the error messages. I have a slight preference towards "negotiated".

// Check option_channel_type in negotiated features since it is assumed to
// be supported.
if !negotiated_features.supports_feature(Features::OPTION_CHANNEL_TYPE) {
return Err("option_channel_type is not supported".to_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: see other comment

Suggested change
return Err("option_channel_type is not supported".to_string());
return Err("option_channel_type has not been negotiated".to_string());

but would also need replacement in tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this check. See: #209 (comment)

Comment on lines +418 to +419
// The pairing is symmetric: an optional bit on the left is satisfied
// by the required bit on the right.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
// The pairing is symmetric: an optional bit on the left is satisfied
// by the required bit on the right.
// The pairing is symmetric: an optional bit on the left is satisfied
// by the required bit on the right and vice versa.

return Err("upfront_shutdown_script is not valid".to_string());
}
} else {
return Err("open_channel does not include upfront_shutdown_script".to_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I like how we use only upfront_shutdown_script here, but we use a channel_type in other places. Probably not worth fixing, but I thought it was worth mentioning.

Comment on lines +126 to +132
if let Some(script) = &open_channel.tlvs.upfront_shutdown_script {
if !script.is_empty() && !is_acceptable_shutdown_script(script, negotiated_features) {
return Err("upfront_shutdown_script is not valid".to_string());
}
} else {
return Err("open_channel does not include upfront_shutdown_script".to_string());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactor suggestion to avoid triple nesting:

diff --git a/smite/src/oracles/accept_channel.rs b/smite/src/oracles/accept_channel.rs
index c07c378..a80d53b 100644
--- a/smite/src/oracles/accept_channel.rs
+++ b/smite/src/oracles/accept_channel.rs
@@ -123,12 +123,11 @@ fn verify_accepted_open_channel(
 
     // Check that the upfront shutdown script is present and valid when negotiated.
     if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) {
-        if let Some(script) = &open_channel.tlvs.upfront_shutdown_script {
-            if !script.is_empty() && !is_acceptable_shutdown_script(script, negotiated_features) {
-                return Err("upfront_shutdown_script is not valid".to_string());
-            }
-        } else {
+        let Some(script) = &open_channel.tlvs.upfront_shutdown_script else {
             return Err("open_channel does not include upfront_shutdown_script".to_string());
+        };
+        if !script.is_empty() && !is_acceptable_shutdown_script(script, negotiated_features) {
+            return Err("upfront_shutdown_script is not valid".to_string());
         }
     }
 
@@ -218,12 +217,11 @@ fn verify_accept_channel(
 ) -> Result<(), String> {
     // Check that the upfront shutdown script is present and valid when negotiated.
     if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) {
-        if let Some(script) = &accept_channel.tlvs.upfront_shutdown_script {
-            if !script.is_empty() && !is_standard_shutdown_script(script, negotiated_features) {
-                return Err("upfront_shutdown_script is not valid".to_string());
-            }
-        } else {
+        let Some(script) = &accept_channel.tlvs.upfront_shutdown_script else {
             return Err("accept_channel does not include upfront_shutdown_script".to_string());
+        };
+        if !script.is_empty() && !is_standard_shutdown_script(script, negotiated_features) {
+            return Err("upfront_shutdown_script is not valid".to_string());
         }
     }

Comment on lines 94 to 97
/// - dust limit greater than channel reserve: BOLT 2 requires the dust limit to
/// be less than or equal to the channel reserve. However, implementations
/// such as LDK accept zero channel reserves on the receiving side, so we do
/// not enforce this check on the target's receiving side.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following checks appear to be unintentionally missing:

  1. unexpected chain_hash
  2. unreasonably large to_self_delay
  3. feerate_per_kw that is too small or unreasonably large

I think we could add 1. by adding expected_chain_hash to AcceptChannelContext. For 2. and 3., we would need to decide which values are too small or unreasonably large. I think we've done this before for other values, i.e. bitcoin amounts > 21m don't make sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Planned as a follow-up, see the PR description
  2. Default Max for all is 2016, so maybe we should also check to_self_delay <= 2016 in open_channel only
  3. The cap on small is already planned, see the PR description. For the large feerate, I don't think it affects any target, since it would only be set by the opener to pay

Comment on lines +16 to +18
const MAX_FUNDING_SATOSHIS_NO_WUMBO: u64 = (1 << 24) - 1;
const MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS: u16 = 114;
const MAX_ACCEPTED_HTLCS_DEFAULT: u16 = 483;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could rename MAX_FUNDING_SATOSHIS_NO_WUMBO to MAX_FUNDING_SATOSHIS_DEFAULT to be consistent with MAX_ACCEPTED_HTLCS_DEFAULT

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer MAX_FUNDING_SATOSHIS_NO_WUMBO only, since MAX_FUNDING_SATOSHIS_DEFAULT could be confused with the Max Bitcoin supply

Comment on lines +138 to +140
if announce_channel && channel_type.supports_feature(Features::OPTION_SCID_ALIAS) {
return Err("option_scid_alias requires the channel to be private".to_string());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the spec sender requirement for this but not the receiver one. Should we update the spec with it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be clarified in the spec, and it would be great if the receiver rejected these. Since HTLCs are rejected immediately rather than held until expiry, the reputation loss would not be significant, though it might eventually affect the target. I think this would be beneficial for the network and nodes, but should not be required

I will update the spec and see what others think about it.

}

// Check the channel reserve covers the dust limit.
if accept_channel.dust_limit_satoshis > accept_channel.channel_reserve_satoshis {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +734 to +757
#[test]
fn commitment_validation_skipped_for_zero_fee_commitments() {
let mut oc = open_channel();
oc.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode());
oc.feerate_per_kw = 0;
oc.push_msat = oc.funding_satoshis * 1000;
oc.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS;
let mut ac = accept_channel();
ac.tlvs.channel_type = Some(ChannelTypeVariant::ZeroFeeCommitments.encode());
ac.max_accepted_htlcs = MAX_ACCEPTED_HTLCS_ZERO_FEE_COMMITMENTS;

assert_pass(&ac, Some(&pending_negotiation(oc)));
}

#[test]
fn commitment_validation_skipped_for_option_simple_taproot() {
let mut oc = open_channel();
oc.tlvs.channel_type = Some(ChannelTypeVariant::SimpleTaproot.encode());
oc.push_msat = oc.funding_satoshis * 1000;
let mut ac = accept_channel();
ac.tlvs.channel_type = Some(ChannelTypeVariant::SimpleTaproot.encode());

assert_pass(&ac, Some(&pending_negotiation(oc)));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, the first test is for an acceptable behavior (i.e. the opener should be able to push the full channel balance out for 0FC since they don't have to pay for the anchor or commitment fee).

But I don't think that's true for taproot channels -- the opener still needs to afford the anchor output and commitment fees. So the second test may actually be something we should eventually reject...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The first test is currently passing by chance and will also pass once we add support for 0FC, so it is fine to keep it as is
  • The second test fails without a barrier and will also fail once we update the commitment fee for taproot channels, It should be updated once support for it is added

Will add comments to both of these tests to make it clear

/// Returns whether every bit set here is supported by `other`, where the
/// feature's required (even) or optional (odd) bit both count as support.
#[must_use]
pub fn is_supported_by(&self, other: &Features) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think it's more consistent with the existing methods to call this supports_features and swap the usage of self and other

fn open_channel_max_accepted_htlcs_above_the_limit() {
fn open_channel_channel_type_contains_non_negotiated_features() {
let mut oc = open_channel();
oc.tlvs.channel_type = Some(vec![0x10, 0x00]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the existing constants here for clarity?

Comment on lines +696 to +698
oc.tlvs.channel_type = Some(vec![0x40, 0x40, 0x10, 0x00]);

let mut negotiated_features = Features::from(vec![0x40, 0x40, 0x10, 0x00]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use constants here for clarity?

// be supported.
if !negotiated_features.supports_feature(Features::OPTION_CHANNEL_TYPE) {
return Err("option_channel_type is not supported".to_string());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. I don't think it's a spec violation to omit the feature bit -- the idea behind making it ASSUMED was to allow reusing the feature bit eventually with a different meaning.

Comment on lines +124 to +125
// Check that the upfront shutdown script is present and valid when negotiated.
if negotiated_features.supports_feature(Features::OPTION_UPFRONT_SHUTDOWN_SCRIPT) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find anywhere in the spec that requires the receiver to validate the sender's shutdown script, and I'm not sure it's needed. IIUC the worst thing that happens in this case is that the sender uses an invalid/unspendable script, which harms themselves, not the receiver.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if the sender script is invalid, a cooperative close is impossible for the channel's lifetime, and if the target accepted it without validating it, the only option to close the channel is a force-close, leaving its to_local output delayed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants