funding: enforce BOLT-02 push_msat bound on fundee
What changed, and why it matters
This commit tightens validation in LND when another node asks to open a Lightning channel. It now rejects requests where the proposed 'push' payment is larger than the entire channel funding, as the Lightning protocol (BOLT-02) already requires. Before this change, such oversized pushes were only caught later in the process, after extra setup work. The change is a defensive hardening fix, not an active exploit patch, and the commit message frames it as spec compliance rather than a security vulnerability.
Treat as a low-to-moderate hardening improvement. No urgent incident response is indicated by the commit itself. Review whether any other protocol bounds in OpenChannel validation are similarly deferred downstream.
Security signals we found
BOLT-02 spec-bound enforcement added for push_msat
Previously oversized push only failed downstream with less specific ErrFunderBalanceDust
New explicit error type ErrPushAmountTooLarge introduced
Validation moved earlier in funding flow, before chanacceptor and commitment negotiation
Test coverage added for both above-bound and at-bound behavior
Evidence from the diff
In funding/manager.go, fundeeProcessOpenChannel now checks push_msat <= 1000 * funding_satoshis before any channel reservation, chanacceptor invocation, or commitment negotiation. It splits push_msat into whole-satoshi and sub-satoshi parts to avoid overflow and rejects with the new ErrPushAmountTooLarge. The existing RejectPush flag still applies only when push_msat > 0. Tests verify both the rejection above the bound and non-rejection at the exact bound. The prior code allowed oversized pushes to proceed until reservation.go computed a negative theirBalance and returned ErrFunderBalanceDust.
Changed components
funding/manager.gofunding/manager_test.golnwallet/errors.goInspect captured patch +145 / −31
diff --git a/funding/manager.go b/funding/manager.go
index 417ad9c..b2adc47 100644
--- a/funding/manager.go
+++ b/funding/manager.go
@@ -1438,14 +1438,64 @@ func (f *Manager) ProcessFundingMsg(msg lnwire.Message, peer lnpeer.Peer) {
func (f *Manager) fundeeProcessOpenChannel(peer lnpeer.Peer,
msg *lnwire.OpenChannel) {
+ amt := msg.FundingAmount
+
+ // Create the channel identifier.
+ cid := newChanIdentifier(msg.PendingChannelID)
+
+ // Enforce BOLT-02: push_msat MUST be <= 1000 * funding_satoshis. We
+ // compare in satoshi space so neither side can overflow uint64 for
+ // any non-negative funding amount: split push_msat into its
+ // integer-satoshi and sub-satoshi parts and reject when it strictly
+ // exceeds the funding amount.
+ pushSat := uint64(msg.PushAmount) / 1000
+ pushSubSat := uint64(msg.PushAmount) % 1000
+ fundingSat := uint64(msg.FundingAmount)
+ if pushSat > fundingSat || (pushSat == fundingSat && pushSubSat > 0) {
+ f.failFundingFlow(
+ peer, cid,
+ lnwallet.ErrPushAmountTooLarge(
+ msg.PushAmount, msg.FundingAmount,
+ ),
+ )
+
+ return
+ }
+
+ // If request specifies non-zero push amount and 'rejectpush' is set,
+ // signal an error.
+ if f.cfg.RejectPush && msg.PushAmount > 0 {
+ f.failFundingFlow(peer, cid, lnwallet.ErrNonZeroPushAmount())
+ return
+ }
+
+ // Ensure that the remote party respects our maximum channel size.
+ if amt > f.cfg.MaxChanSize {
+ f.failFundingFlow(
+ peer, cid,
+ lnwallet.ErrChanTooLarge(amt, f.cfg.MaxChanSize),
+ )
+
+ return
+ }
+
+ // We'll, also ensure that the remote party isn't attempting to propose
+ // a channel that's below our current min channel size.
+ if amt < f.cfg.MinChanSize {
+ f.failFundingFlow(
+ peer, cid,
+ lnwallet.ErrChanTooSmall(amt, f.cfg.MinChanSize),
+ )
+
+ return
+ }
+
// Check number of pending channels to be smaller than maximum allowed
// number and send ErrorGeneric to remote peer if condition is
// violated.
peerPubKey := peer.IdentityKey()
peerIDKey := newSerializedKey(peerPubKey)
- amt := msg.FundingAmount
-
// We get all pending channels for this peer. This is the list of the
// active reservations and the channels pending open in the database.
f.resMtx.RLock()
@@ -1462,9 +1512,6 @@ func (f *Manager) fundeeProcessOpenChannel(peer lnpeer.Peer,
}
f.resMtx.RUnlock()
- // Create the channel identifier.
- cid := newChanIdentifier(msg.PendingChannelID)
-
// Also count the channels that are already pending. There we don't know
// the underlying intent anymore, unfortunately.
channels, err := f.cfg.ChannelDB.FetchOpenChannels(peerPubKey)
@@ -1518,32 +1565,6 @@ func (f *Manager) fundeeProcessOpenChannel(peer lnpeer.Peer,
return
}
- // Ensure that the remote party respects our maximum channel size.
- if amt > f.cfg.MaxChanSize {
- f.failFundingFlow(
- peer, cid,
- lnwallet.ErrChanTooLarge(amt, f.cfg.MaxChanSize),
- )
- return
- }
-
- // We'll, also ensure that the remote party isn't attempting to propose
- // a channel that's below our current min channel size.
- if amt < f.cfg.MinChanSize {
- f.failFundingFlow(
- peer, cid,
- lnwallet.ErrChanTooSmall(amt, f.cfg.MinChanSize),
- )
- return
- }
-
- // If request specifies non-zero push amount and 'rejectpush' is set,
- // signal an error.
- if f.cfg.RejectPush && msg.PushAmount > 0 {
- f.failFundingFlow(peer, cid, lnwallet.ErrNonZeroPushAmount())
- return
- }
-
// Send the OpenChannel request to the ChannelAcceptor to determine
// whether this node will accept the channel.
chanReq := &chanacceptor.ChannelAcceptRequest{
diff --git a/funding/manager_test.go b/funding/manager_test.go
index 4924eec..9ddac92 100644
--- a/funding/manager_test.go
+++ b/funding/manager_test.go
@@ -3887,6 +3887,87 @@ func TestFundingManagerRejectPush(t *testing.T) {
)
}
+// TestFundingManagerPushAmountExceedsCapacity asserts that the fundee
+// rejects an incoming OpenChannel whose push_msat exceeds
+// 1000 * funding_satoshis, as required by BOLT-02.
+func TestFundingManagerPushAmountExceedsCapacity(t *testing.T) {
+ t.Parallel()
+
+ alice, bob := setupFundingManagers(t)
+ t.Cleanup(func() {
+ tearDownFundingManagers(t, alice, bob)
+ })
+
+ // Build an OpenChannel directly with a push amount that strictly
+ // exceeds 1000 * funding_satoshis. We only need the fields that
+ // Bob's fundeeProcessOpenChannel inspects before the BOLT-02 bound
+ // check, so other fields are left zero.
+ const fundingAmt = btcutil.Amount(500000)
+ openChannelReq := &lnwire.OpenChannel{
+ ChainHash: *fundingNetParams.GenesisHash,
+ PendingChannelID: [32]byte{0x01},
+ FundingAmount: fundingAmt,
+ PushAmount: lnwire.NewMSatFromSatoshis(fundingAmt) + 1,
+ }
+
+ bob.fundingMgr.ProcessFundingMsg(openChannelReq, alice)
+
+ // Bob should respond with an Error that carries the
+ // ErrPushAmountTooLarge message.
+ msg := assertFundingMsgSent(t, bob.msgChan, "Error")
+ err, ok := msg.(*lnwire.Error)
+ require.True(t, ok, "expected *lnwire.Error, got %T", msg)
+
+ expected := lnwallet.ErrPushAmountTooLarge(
+ openChannelReq.PushAmount, openChannelReq.FundingAmount,
+ )
+ require.Equal(t, expected.Error(), string(err.Data))
+}
+
+// TestFundingManagerPushAmountAtCapacity asserts that the fundee does NOT
+// reject an incoming OpenChannel with the BOLT-02 push-bound error when
+// push_msat exactly equals 1000 * funding_satoshis. The spec permits this
+// boundary (push_msat MUST be <= 1000 * funding_satoshis), so the check
+// added in fundeeProcessOpenChannel must not fire on equality. The flow
+// may still fail downstream for unrelated reasons (e.g. funder balance
+// dust after fees), but never with ErrPushAmountTooLarge.
+func TestFundingManagerPushAmountAtCapacity(t *testing.T) {
+ t.Parallel()
+
+ alice, bob := setupFundingManagers(t)
+ t.Cleanup(func() {
+ tearDownFundingManagers(t, alice, bob)
+ })
+
+ const fundingAmt = btcutil.Amount(500000)
+ openChannelReq := &lnwire.OpenChannel{
+ ChainHash: *fundingNetParams.GenesisHash,
+ PendingChannelID: [32]byte{0x01},
+ FundingAmount: fundingAmt,
+ PushAmount: lnwire.NewMSatFromSatoshis(fundingAmt),
+ }
+
+ bob.fundingMgr.ProcessFundingMsg(openChannelReq, alice)
+
+ // Whatever response Bob produces, it must not be the BOLT-02
+ // push-bound error: the boundary is spec-legal.
+ forbidden := lnwallet.ErrPushAmountTooLarge(
+ openChannelReq.PushAmount, openChannelReq.FundingAmount,
+ ).Error()
+
+ select {
+ case msg := <-bob.msgChan:
+ errMsg, ok := msg.(*lnwire.Error)
+ if !ok {
+ return
+ }
+ require.NotEqual(t, forbidden, string(errMsg.Data),
+ "fundee rejected spec-legal boundary push_msat == "+
+ "1000 * funding_satoshis")
+ case <-time.After(time.Second):
+ }
+}
+
// TestFundingManagerMaxConfs ensures that we don't accept a funding proposal
// that proposes a MinAcceptDepth greater than the maximum number of
// confirmations we're willing to accept.
diff --git a/lnwallet/errors.go b/lnwallet/errors.go
index b2d1a5c..e21af48 100644
--- a/lnwallet/errors.go
+++ b/lnwallet/errors.go
@@ -85,6 +85,18 @@ func ErrNonZeroPushAmount() ReservationError {
return ReservationError{errors.New("non-zero push amounts are disabled")}
}
+// ErrPushAmountTooLarge is returned when the push amount exceeds the channel's
+// funding amount, which violates BOLT-02 (push_msat MUST be <=
+// 1000 * funding_satoshis).
+func ErrPushAmountTooLarge(pushAmt lnwire.MilliSatoshi,
+ fundingAmt btcutil.Amount) ReservationError {
+
+ return ReservationError{
+ fmt.Errorf("push amount %v exceeds funding amount %v",
+ pushAmt, lnwire.NewMSatFromSatoshis(fundingAmt)),
+ }
+}
+
// ErrMinHtlcTooLarge returns an error indicating that the MinHTLC value the
// remote required is too large to be accepted.
func ErrMinHtlcTooLarge(minHtlc,
Why this scored 45/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.