Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/release-notes/release-notes-0.22.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@

## BOLT Spec Updates

* Added support for [attributable failures (feature
36/37)](https://github.com/lightningnetwork/lnd/pull/9888) per BOLT #1044.
Per-hop hold times reported via the new `attribution_data` field on
`update_fail_htlc` are exposed to senders through `Failure.hold_times` on
`SendPaymentV2` results.

* The fundee now [enforces the BOLT-02 bound on
`push_msat`](https://github.com/lightningnetwork/lnd/pull/10765),
rejecting incoming `open_channel` messages where `push_msat` exceeds
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,7 @@ replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-d
// well).
go 1.25.5

// Temporary replace until dependent PR is merged in lightning-onion.
replace github.com/lightningnetwork/lightning-onion => github.com/GeorgeTsagk/lightning-onion v0.0.0-20260311122656-2ec96bf9d0e9

retract v0.0.2
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GeorgeTsagk/lightning-onion v0.0.0-20260311122656-2ec96bf9d0e9 h1:8GJQpRwKnX27toHLtOYEiYMVMp13CH6wrYkLSLf9rWM=
github.com/GeorgeTsagk/lightning-onion v0.0.0-20260311122656-2ec96bf9d0e9/go.mod h1:nP85zMHG7c0si/eHBbSQpuDCtnIXfSvFrK3tW6YWzmU=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4=
Expand Down Expand Up @@ -309,8 +311,6 @@ github.com/lightninglabs/neutrino/cache v1.1.3 h1:rgnabC41W+XaPuBTQrdeFjFCCAVKh1
github.com/lightninglabs/neutrino/cache v1.1.3/go.mod h1:qxkJb+pUxR5p84jl5uIGFCR4dGdFkhNUwMSxw3EUWls=
github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display h1:Y2WiPkBS/00EiEg0qp0FhehxnQfk3vv8U6Xt3nN+rTY=
github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
github.com/lightningnetwork/lightning-onion v1.3.0 h1:FqILgHjD6euc/Muo1VOzZ4+XDPuFnw6EYROBq0rR/5c=
github.com/lightningnetwork/lightning-onion v1.3.0/go.mod h1:nP85zMHG7c0si/eHBbSQpuDCtnIXfSvFrK3tW6YWzmU=
github.com/lightningnetwork/lnd/actor v0.0.6 h1:Ge8N2wivARG+27qJBwTlB0vwsypStZYZy8vk4Zl38sU=
github.com/lightningnetwork/lnd/actor v0.0.6/go.mod h1:YAsoniSbY/cAM9HTVNfZLvt7RI6swDxy6wzPspTcMZg=
github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI=
Expand Down
240 changes: 240 additions & 0 deletions htlcswitch/attributable_failure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package htlcswitch

import (
"bytes"
"testing"
"time"

"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)

// deriveSharedSecrets derives the shared secrets for each hop along the payment
// path using the session key, mirroring the logic of sphinx's internal
// generateSharedSecrets.
func deriveSharedSecrets(t *testing.T, paymentPath []*btcec.PublicKey,
sessionKey *btcec.PrivateKey) []sphinx.Hash256 {

t.Helper()

numHops := len(paymentPath)
secrets := make([]sphinx.Hash256, numHops)

ephemECDH := &sphinx.PrivKeyECDH{PrivKey: sessionKey}

// First hop.
ss, err := ephemECDH.ECDH(paymentPath[0])
require.NoError(t, err)
secrets[0] = ss

// Subsequent hops: derive the next ephemeral private key using the
// blinding factor.
for i := 1; i < numHops; i++ {
nextPriv, err := sphinx.NextEphemeralPriv(
ephemECDH, paymentPath[i-1],
)
require.NoError(t, err)

ephemECDH = &sphinx.PrivKeyECDH{PrivKey: nextPriv}

ss, err = ephemECDH.ECDH(paymentPath[i])
require.NoError(t, err)
secrets[i] = ss
}

return secrets
}

// TestAttributableFailureEndToEnd exercises the full encrypt → intermediate
// encrypt → decrypt flow with attribution data and validates that HoldTimes
// are correctly populated.
func TestAttributableFailureEndToEnd(t *testing.T) {
t.Parallel()

const numHops = 4

// Generate random node keys for the payment path.
paymentPath := make([]*btcec.PublicKey, numHops)
for i := 0; i < numHops; i++ {
privKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
paymentPath[i] = privKey.PubKey()
}

// Use a deterministic session key.
sessionKey, _ := btcec.PrivKeyFromBytes(
bytes.Repeat([]byte{0x42}, 32),
)

// Derive per-hop shared secrets.
sharedSecrets := deriveSharedSecrets(t, paymentPath, sessionKey)

// The failing node is hop index 2 (third node, 0-indexed).
failingHopIdx := 2

// Create a failure message at the failing hop.
failureMsg := lnwire.NewFailIncorrectDetails(1000, 100)

// Create the error encrypter at the failing hop, with a creation time
// slightly in the past to get a non-zero hold time.
failEncrypter := hop.NewSphinxErrorEncrypter(
paymentPath[failingHopIdx],
sharedSecrets[failingHopIdx],
)
failEncrypter.CreatedAt = time.Now().Add(-200 * time.Millisecond)

// Encrypt at the origin of the failure.
reason, attrData, err := failEncrypter.EncryptFirstHop(failureMsg)
require.NoError(t, err)
require.NotEmpty(t, reason)
require.NotEmpty(t, attrData, "attribution data should be populated")

// Wrap the attribution data in ExtraOpaqueData for transmission.
extraData, err := lnwire.AttrDataToExtraData(attrData)
require.NoError(t, err)

// Intermediate encrypt at each hop back to the sender.
for i := failingHopIdx - 1; i >= 0; i-- {
intermediateEnc := hop.NewSphinxErrorEncrypter(
paymentPath[i],
sharedSecrets[i],
)
// Set a slightly older creation time to simulate hold time.
intermediateEnc.CreatedAt = time.Now().Add(
-100 * time.Millisecond,
)

// Extract attr data from the extra data (as it would come from
// the wire message).
attrData, err = lnwire.ExtraDataToAttrData(extraData)
require.NoError(t, err)

reason, attrData, err = intermediateEnc.IntermediateEncrypt(
reason, attrData,
)
require.NoError(t, err)

extraData, err = lnwire.AttrDataToExtraData(attrData)
require.NoError(t, err)
}

// Now decrypt at the sender using the SphinxErrorDecrypter.
circuit := &sphinx.Circuit{
SessionKey: sessionKey,
PaymentPath: paymentPath,
}
decrypter := NewSphinxErrorDecrypter(circuit)

attrData, err = lnwire.ExtraDataToAttrData(extraData)
require.NoError(t, err)

fwdErr, err := decrypter.DecryptError(reason, attrData)
require.NoError(t, err)

// Verify the failure source is identified correctly.
// SenderIdx is 1-indexed (0 = self), so failing hop index 2 means
// SenderIdx = 3.
require.Equal(t, failingHopIdx+1, fwdErr.FailureSourceIdx,
"failure source index mismatch")

// Verify we got the right failure message back.
wireMsg := fwdErr.WireMessage()
incorrectDetails, ok := wireMsg.(*lnwire.FailIncorrectDetails)
require.True(t, ok, "expected FailIncorrectDetails, got %T", wireMsg)
require.EqualValues(t, 1000, incorrectDetails.Amount())
require.EqualValues(t, 100, incorrectDetails.Height())

// Verify that HoldTimes are populated. We should have hold times for
// hops 1 through failingHopIdx (the failing node plus intermediates).
require.NotEmpty(t, fwdErr.HoldTimes,
"expected non-empty hold times")
}

// TestAttributableFailureWithoutAttrData tests that decryption works without
// attribution data (backward compatibility with non-attributable errors).
func TestAttributableFailureWithoutAttrData(t *testing.T) {
t.Parallel()

const numHops = 3

paymentPath := make([]*btcec.PublicKey, numHops)
for i := 0; i < numHops; i++ {
privKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
paymentPath[i] = privKey.PubKey()
}

sessionKey, _ := btcec.PrivKeyFromBytes(
bytes.Repeat([]byte{0x33}, 32),
)

sharedSecrets := deriveSharedSecrets(t, paymentPath, sessionKey)

// Failing hop is the last node.
failingHopIdx := numHops - 1

failureMsg := lnwire.NewFailIncorrectDetails(500, 50)

failEncrypter := hop.NewSphinxErrorEncrypter(
paymentPath[failingHopIdx],
sharedSecrets[failingHopIdx],
)

reason, _, err := failEncrypter.EncryptFirstHop(failureMsg)
require.NoError(t, err)

// Intermediate hops encrypt WITHOUT using attribution data (passing
// nil), simulating nodes that don't support attributable failures.
for i := failingHopIdx - 1; i >= 0; i-- {
intermediateEnc := hop.NewSphinxErrorEncrypter(
paymentPath[i],
sharedSecrets[i],
)

reason, _, err = intermediateEnc.IntermediateEncrypt(
reason, nil,
)
require.NoError(t, err)
}

// Decrypt at the sender without attribution data.
circuit := &sphinx.Circuit{
SessionKey: sessionKey,
PaymentPath: paymentPath,
}
decrypter := NewSphinxErrorDecrypter(circuit)

fwdErr, err := decrypter.DecryptError(reason, nil)
require.NoError(t, err)

// The failure source should still be correctly identified via the
// legacy HMAC-based mechanism.
require.Equal(t, failingHopIdx+1, fwdErr.FailureSourceIdx)

wireMsg := fwdErr.WireMessage()
incorrectDetails, ok := wireMsg.(*lnwire.FailIncorrectDetails)
require.True(t, ok)
require.EqualValues(t, 500, incorrectDetails.Amount())
}

// TestNewForwardingErrorHoldTimes verifies that NewForwardingError correctly
// stores and exposes HoldTimes.
func TestNewForwardingErrorHoldTimes(t *testing.T) {
t.Parallel()

holdTimes := []uint32{10, 20, 30, 40}
failure := lnwire.NewFailIncorrectDetails(100, 10)

fwdErr := NewForwardingError(failure, 3, holdTimes)

require.Equal(t, 3, fwdErr.FailureSourceIdx)
require.Equal(t, holdTimes, fwdErr.HoldTimes)
require.NotNil(t, fwdErr.WireMessage())

// With nil hold times.
fwdErr2 := NewForwardingError(failure, 1, nil)
require.Nil(t, fwdErr2.HoldTimes)
}
7 changes: 4 additions & 3 deletions htlcswitch/circuit.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,17 +199,18 @@ func (c *PaymentCircuit) Decode(r io.Reader) error {

case hop.EncrypterTypeSphinx:
// Sphinx encrypter was used as this is a forwarded HTLC.
c.ErrorEncrypter = hop.NewSphinxErrorEncrypter()
c.ErrorEncrypter = hop.NewSphinxErrorEncrypterUninitialized()

case hop.EncrypterTypeMock:
// Test encrypter.
c.ErrorEncrypter = NewMockObfuscator()

case hop.EncrypterTypeIntroduction:
c.ErrorEncrypter = hop.NewIntroductionErrorEncrypter()
c.ErrorEncrypter =
hop.NewIntroductionErrorEncrypterUninitialized()

case hop.EncrypterTypeRelaying:
c.ErrorEncrypter = hop.NewRelayingErrorEncrypter()
c.ErrorEncrypter = hop.NewRelayingErrorEncrypterUninitialized()

default:
return UnknownEncrypterType(encrypterType)
Expand Down
10 changes: 4 additions & 6 deletions htlcswitch/circuit_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,9 @@ type CircuitMapConfig struct {
FetchClosedChannels func(
pendingOnly bool) ([]*channeldb.ChannelCloseSummary, error)

// ExtractErrorEncrypter derives the shared secret used to encrypt
// errors from the obfuscator's ephemeral public key.
ExtractErrorEncrypter hop.ErrorEncrypterExtracter
// ExtractSharedSecret derives the shared secret used to encrypt errors
// from the obfuscator's ephemeral public key.
ExtractSharedSecret hop.SharedSecretGenerator

// CheckResolutionMsg checks whether a given resolution message exists
// for the passed CircuitKey.
Expand Down Expand Up @@ -632,9 +632,7 @@ func (cm *circuitMap) decodeCircuit(v []byte) (*PaymentCircuit, error) {

// Otherwise, we need to reextract the encrypter, so that the shared
// secret is rederived from what was decoded.
err := circuit.ErrorEncrypter.Reextract(
cm.cfg.ExtractErrorEncrypter,
)
err := circuit.ErrorEncrypter.Reextract(cm.cfg.ExtractSharedSecret)
if err != nil {
return nil, err
}
Expand Down
31 changes: 16 additions & 15 deletions htlcswitch/circuit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,17 @@ func initTestExtracter() {
onionProcessor := newOnionProcessor(nil)
defer onionProcessor.Stop()

obfuscator, _ := onionProcessor.ExtractErrorEncrypter(
sharedSecret, failCode := onionProcessor.ExtractSharedSecret(
testEphemeralKey,
)

sphinxExtracter, ok := obfuscator.(*hop.SphinxErrorEncrypter)
if !ok {
panic("did not extract sphinx error encrypter")
if failCode != lnwire.CodeNone {
panic("did not extract shared secret")
}

testExtracter = sphinxExtracter
testExtracter = hop.NewSphinxErrorEncrypter(
testEphemeralKey, sharedSecret,
)

// We also set this error extracter on startup, otherwise it will be nil
// at compile-time.
Expand Down Expand Up @@ -106,10 +107,10 @@ func newCircuitMap(t *testing.T, resMsg bool) (*htlcswitch.CircuitMapConfig,

db := makeCircuitDB(t, "")
circuitMapCfg := &htlcswitch.CircuitMapConfig{
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractErrorEncrypter: onionProcessor.ExtractErrorEncrypter,
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractSharedSecret: onionProcessor.ExtractSharedSecret,
}

if resMsg {
Expand Down Expand Up @@ -216,7 +217,7 @@ func TestHalfCircuitSerialization(t *testing.T) {
// encrypters, this will be a NOP.
if circuit2.ErrorEncrypter != nil {
err := circuit2.ErrorEncrypter.Reextract(
onionProcessor.ExtractErrorEncrypter,
onionProcessor.ExtractSharedSecret,
)
if err != nil {
t.Fatalf("unable to reextract sphinx error "+
Expand Down Expand Up @@ -643,11 +644,11 @@ func restartCircuitMap(t *testing.T, cfg *htlcswitch.CircuitMapConfig) (
// Reinitialize circuit map with same db path.
db := makeCircuitDB(t, dbPath)
cfg2 := &htlcswitch.CircuitMapConfig{
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractErrorEncrypter: cfg.ExtractErrorEncrypter,
CheckResolutionMsg: cfg.CheckResolutionMsg,
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractSharedSecret: cfg.ExtractSharedSecret,
CheckResolutionMsg: cfg.CheckResolutionMsg,
}
cm2, err := htlcswitch.NewCircuitMap(cfg2)
require.NoError(t, err, "unable to recreate persistent circuit map")
Expand Down
Loading
Loading