From 229be2a83f0dc64ed18855c1b59be9a16f5455c3 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 30 Jun 2026 15:38:32 +0200 Subject: [PATCH] itest: cover blinded route next_node_id forwarding Add integration tests for an lnd introduction node forwarding a blinded payment whose non-final hops identify the next hop by node ID (next_node_id) rather than a short channel ID, as produced by other implementations: - testBlindedRouteNextNodeID: the outgoing channel is public. - testBlindedRouteNextNodeIDPrivateChannel: the outgoing channel is private, so the node ID resolves to an SCID alias. - testBlindedRouteNextNodeIDRestart: the introduction node is restarted while the HTLC is in flight, exercising forwarding-package replay and re-decode of the node-ID blinded hop. (cherry picked from commit da6a40c01d4963df8a462672b423e059d9a859a8) --- itest/list_on_test.go | 12 + itest/lnd_route_blinding_test.go | 420 +++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 02fd01218..6c9feacbe 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -591,6 +591,18 @@ var allTestCases = []*lntest.TestCase{ Name: "blinded payment htlc re-forward", TestFunc: testBlindedPaymentHTLCReForward, }, + { + Name: "blinded route next node id", + TestFunc: testBlindedRouteNextNodeID, + }, + { + Name: "blinded route next node id private channel", + TestFunc: testBlindedRouteNextNodeIDPrivateChannel, + }, + { + Name: "blinded route next node id restart", + TestFunc: testBlindedRouteNextNodeIDRestart, + }, { Name: "query blinded route", TestFunc: testQueryBlindedRoutes, diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index af2612d24..a387e907a 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -1,6 +1,7 @@ package itest import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -10,12 +11,16 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/chainreg" + "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/node" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -383,6 +388,78 @@ func (b *blindedForwardTest) setupNetwork(ctx context.Context, } } +// setupNetworkPrivateMiddle sets up the same Alice -> Bob -> Carol -> Dave +// network as setupNetwork (with an interceptor on Carol), except that the +// Bob -> Carol channel is private. This is the channel the introduction node +// (Bob) must resolve to from Carol's node ID, exercising resolution to an SCID +// alias of an unadvertised channel. +func (b *blindedForwardTest) setupNetworkPrivateMiddle(ctx context.Context) { + carolArgs := []string{ + "--bitcoin.timelockdelta=24", + fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), + "--requireinterceptor", + } + daveArgs := []string{ + "--bitcoin.timelockdelta=24", + fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), + } + + alice := b.ht.NewNode("Alice", nil) + bob := b.ht.NewNode("Bob", nil) + carol := b.ht.NewNode("Carol", carolArgs) + dave := b.ht.NewNode("Dave", daveArgs) + b.alice, b.bob, b.carol, b.dave = alice, bob, carol, dave + + b.ht.EnsureConnected(alice, bob) + b.ht.EnsureConnected(bob, carol) + b.ht.EnsureConnected(carol, dave) + + // Fund every node that opens a channel. + const chanAmt = btcutil.Amount(100_000) + b.ht.FundCoins(btcutil.SatoshiPerBitcoin, alice) + b.ht.FundCoins(btcutil.SatoshiPerBitcoin, bob) + b.ht.FundCoins(btcutil.SatoshiPerBitcoin, carol) + + // Open Alice -> Bob and Carol -> Dave as public channels, but Bob -> + // Carol (the hop the introduction node must resolve by node ID) as a + // private channel, so it is only reachable via an SCID alias. + reqs := []*lntest.OpenChannelRequest{ + { + Local: alice, + Remote: bob, + Param: lntest.OpenChannelParams{Amt: chanAmt}, + }, + { + Local: bob, + Remote: carol, + Param: lntest.OpenChannelParams{ + Amt: chanAmt, + Private: true, + }, + }, + { + Local: carol, + Remote: dave, + Param: lntest.OpenChannelParams{Amt: chanAmt}, + }, + } + b.channels = b.ht.OpenMultiChannelsAsync(reqs) + + // Alice must know the public Alice -> Bob channel to build a route to + // the introduction node, and Bob and Carol must both know the private + // Bob -> Carol channel used for forwarding. + b.ht.AssertChannelInGraph(alice, b.channels[0]) + b.ht.AssertChannelInGraph(bob, b.channels[0]) + b.ht.AssertChannelInGraph(bob, b.channels[1]) + b.ht.AssertChannelInGraph(carol, b.channels[1]) + b.ht.AssertChannelInGraph(carol, b.channels[2]) + b.ht.AssertChannelInGraph(dave, b.channels[2]) + + var err error + b.carolInterceptor, err = b.carol.RPC.Router.HtlcInterceptor(ctx) + require.NoError(b.ht, err, "interceptor") +} + // buildBlindedPath returns a blinded route from Bob -> Carol -> Dave, with Bob // acting as the introduction point. func (b *blindedForwardTest) buildBlindedPath() *lnrpc.BlindedPaymentPath { @@ -1421,6 +1498,349 @@ func testBlindedPaymentHTLCReForward(ht *lntest.HarnessTest) { } } +// nextNodeIDRouteData builds the recipient data for a non-final blinded hop +// that identifies the next hop by its node ID (next_node_id) rather than a +// short channel ID. This is the form of recipient data that a non-lnd +// implementation may produce and that the forwarding node must resolve to one +// of its active channels. +func nextNodeIDRouteData(nextNode *btcec.PublicKey, + relayInfo record.PaymentRelayInfo, + constraints *record.PaymentConstraints) *record.BlindedRouteData { + + return &record.BlindedRouteData{ + NextNodeID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nextNode), + ), + RelayInfo: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType10](relayInfo), + ), + Constraints: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12](*constraints), + ), + } +} + +// buildBlindedPathWithNextNodeID constructs a Bob -> Carol -> Dave blinded path +// in which the non-final hops (Bob and Carol) identify their next hop by node +// ID instead of a short channel ID. Bob is the introduction node. The returned +// path can be used to exercise an lnd forwarding node's ability to resolve a +// next_node_id to one of its active channels. +func (b *blindedForwardTest) buildBlindedPathWithNextNodeID( + paymentAmt int64) *lnrpc.BlindedPaymentPath { + + bobPub, err := btcec.ParsePubKey(b.bob.PubKey[:]) + require.NoError(b.ht, err) + + carolPub, err := btcec.ParsePubKey(b.carol.PubKey[:]) + require.NoError(b.ht, err) + + davePub, err := btcec.ParsePubKey(b.dave.PubKey[:]) + require.NoError(b.ht, err) + + // Use zero fees so that the forwarded amount remains constant along the + // path, keeping the route math trivial. + const ( + hopCltvDelta uint16 = 144 + finalCltvDelta uint32 = 24 + ) + + // Set a generous max CLTV constraint so that the incoming expiry at + // each hop never trips the payment constraints check. + info := b.alice.RPC.GetInfo() + constraints := &record.PaymentConstraints{ + MaxCltvExpiry: info.BlockHeight + 10_000, + HtlcMinimumMsat: 0, + } + relayInfo := record.PaymentRelayInfo{ + CltvExpiryDelta: hopCltvDelta, + FeeRate: 0, + BaseFee: 0, + } + + // Bob (the introduction node) forwards to Carol and Carol forwards to + // Dave, each identified purely by node ID. Dave is the final hop; its + // path ID is arbitrary because the payment is settled at Carol via the + // interceptor before it ever reaches Dave. + hopData := []struct { + pub *btcec.PublicKey + data *record.BlindedRouteData + }{ + { + pub: bobPub, + data: nextNodeIDRouteData( + carolPub, relayInfo, constraints, + ), + }, + { + pub: carolPub, + data: nextNodeIDRouteData( + davePub, relayInfo, constraints, + ), + }, + { + pub: davePub, + data: record.NewFinalHopBlindedRouteData( + constraints, bytes.Repeat([]byte{1}, 32), + ), + }, + } + + paymentPath := make([]*sphinx.HopInfo, len(hopData)) + for i, hop := range hopData { + plainText, err := record.EncodeBlindedRouteData(hop.data) + require.NoError(b.ht, err) + + paymentPath[i] = &sphinx.HopInfo{ + NodePub: hop.pub, + PlainText: plainText, + } + } + + // Encrypt the per-hop data into a blinded path using a fresh session + // key. + sessionKey, err := btcec.NewPrivateKey() + require.NoError(b.ht, err) + + blindedPathInfo, err := sphinx.BuildBlindedPath(sessionKey, paymentPath) + require.NoError(b.ht, err) + blindedPath := blindedPathInfo.Path + + // The introduction node is communicated in plaintext, so overwrite the + // first hop's blinded pub key with the real introduction point. + blindedPath.BlindedHops[0].BlindedNodePub = + blindedPath.IntroductionPoint + + blindedHops := make( + []*lnrpc.BlindedHop, len(blindedPath.BlindedHops), + ) + for i, hop := range blindedPath.BlindedHops { + blindedHops[i] = &lnrpc.BlindedHop{ + BlindedNode: hop.BlindedNodePub.SerializeCompressed(), + EncryptedData: hop.CipherText, + } + } + + return &lnrpc.BlindedPaymentPath{ + BlindedPath: &lnrpc.BlindedPath{ + IntroductionNode: b.bob.PubKey[:], + BlindingPoint: blindedPath.BlindingPoint. + SerializeCompressed(), + BlindedHops: blindedHops, + }, + BaseFeeMsat: 0, + TotalCltvDelta: 2*uint32(hopCltvDelta) + finalCltvDelta, + HtlcMinMsat: 0, + HtlcMaxMsat: uint64(paymentAmt) * 2, + } +} + +// testBlindedRouteNextNodeID tests that an lnd node acting as the introduction +// node of a blinded path can forward a payment when the recipient identifies +// the next hop by its node ID (next_node_id) rather than a short channel ID. +// The introduction node must resolve the node ID to one of its active channels +// with that peer. +func testBlindedRouteNextNodeID(ht *lntest.HarnessTest) { + ctx, testCase := newBlindedForwardTest(ht) + defer testCase.cleanup() + + // Set up the Alice -> Bob -> Carol -> Dave network with an interceptor + // on Carol. Bob is the introduction node whose node ID resolution we + // want to exercise, and Carol's interceptor lets us deterministically + // observe that Bob successfully resolved and forwarded the HTLC. + testCase.setupNetwork(ctx, true) + + testCase.runNextNodeIDForward(ctx, nil) +} + +// testBlindedRouteNextNodeIDPrivateChannel is like testBlindedRouteNextNodeID, +// but the Bob -> Carol channel that the introduction node must resolve by node +// ID is private. This exercises the introduction node's ability to resolve the +// next node's ID to an SCID alias of an unadvertised channel (option-scid-alias +// channels are not forwardable by their confirmed SCID). +func testBlindedRouteNextNodeIDPrivateChannel(ht *lntest.HarnessTest) { + ctx, testCase := newBlindedForwardTest(ht) + defer testCase.cleanup() + + // Set up Alice -> Bob -> Carol -> Dave where the Bob -> Carol channel + // is private, so Bob must resolve Carol's node ID to that channel's + // alias. + testCase.setupNetworkPrivateMiddle(ctx) + + testCase.runNextNodeIDForward(ctx, nil) +} + +// testBlindedRouteNextNodeIDRestart tests that a blinded payment forwarded by +// node ID survives a restart of the introduction node. The HTLC is held at the +// receiver's interceptor after the introduction node (Bob) has resolved the +// next node's ID and forwarded it. Bob is then restarted, forcing it to replay +// its forwarding package and re-decode the node-ID blinded hop, after which the +// in-flight HTLC must remain intact and the payment must still settle. +func testBlindedRouteNextNodeIDRestart(ht *lntest.HarnessTest) { + ctx, testCase := newBlindedForwardTest(ht) + defer testCase.cleanup() + + testCase.setupNetwork(ctx, true) + + // Open a second, parallel Bob -> Carol channel with zero fees, matching + // the zero-fee policy runNextNodeIDForward sets on channels[1]. The + // blinded path identifies the hop by Carol's node ID, so both Bob -> + // Carol channels are valid candidates and Bob's non-strict forwarding + // picks one at random. We use this to prove that replaying the + // forwarding package after a restart re-pins the same randomly selected + // channel and does not duplicate the HTLC onto the other one. + ht.FundCoins(btcutil.SatoshiPerBitcoin, testCase.bob) + parallel := ht.OpenChannel( + testCase.bob, testCase.carol, + lntest.OpenChannelParams{Amt: chanAmt}, + ) + testCase.bob.RPC.UpdateChannelPolicy(&lnrpc.PolicyUpdateRequest{ + Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{ + ChanPoint: parallel, + }, + BaseFeeMsat: 0, + FeeRatePpm: 0, + TimeLockDelta: 80, + }) + + testCase.runNextNodeIDForward(ctx, func() { + hash := sha256.Sum256(testCase.preimage[:]) + + // Non-strict forwarding picked one of the two Bob -> Carol + // channels at random. Find which one currently carries the + // outgoing HTLC so we can assert it stays there across the + // restart. + chosen, other := testCase.channels[1], parallel + if channelHasHTLC(ht, testCase.bob, parallel, hash[:]) { + chosen, other = parallel, testCase.channels[1] + } + + // Restart the introduction node while the HTLC is held at + // Carol's interceptor. On startup Bob replays its forwarding + // package and must re-decode the node-ID blinded hop without + // disturbing the already forwarded HTLC. + ht.RestartNode(testCase.bob) + ht.EnsureConnected(testCase.alice, testCase.bob) + ht.EnsureConnected(testCase.bob, testCase.carol) + + // After replaying its forwarding package, the in-flight HTLC + // must still be on the originally selected channel and must not + // have been duplicated onto the other Bob -> Carol channel. Bob + // therefore holds exactly two active HTLCs: the incoming one + // from Alice and the single outgoing one to Carol. + ht.AssertOutgoingHTLCActive(testCase.bob, chosen, hash[:]) + ht.AssertHTLCNotActive(testCase.bob, other, hash[:]) + ht.AssertNumActiveHtlcs(testCase.bob, 2) + }) +} + +// channelHasHTLC reports whether the given channel currently has a pending +// HTLC locked in for the provided payment hash. +func channelHasHTLC(ht *lntest.HarnessTest, hn *node.HarnessNode, + cp *lnrpc.ChannelPoint, hash []byte) bool { + + channel := ht.GetChannelByChanPoint(hn, cp) + for _, htlc := range channel.PendingHtlcs { + if bytes.Equal(htlc.HashLock, hash) { + return true + } + } + + return false +} + +// runNextNodeIDForward drives a payment along a blinded path whose non-final +// hops identify the next hop by node ID, asserting that the lnd introduction +// node (Bob) resolves the node ID to one of its channels and forwards the HTLC +// to Carol, who settles it via her interceptor. If midFlight is non-nil it is +// invoked while the HTLC is held at Carol's interceptor, before it is settled, +// letting callers exercise behaviour such as restarting the introduction node. +func (b *blindedForwardTest) runNextNodeIDForward(ctx context.Context, + midFlight func()) { + + ht := b.ht + + // Since buildBlindedPathWithNextNodeID constructs a path with zero + // fees to keep routing math trivial, we must update Bob's outgoing + // channel policy to have zero fees so that forwarding is not rejected + // with FeeInsufficient. + bobUpdateReq := &lnrpc.PolicyUpdateRequest{ + Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{ + ChanPoint: b.channels[1], + }, + BaseFeeMsat: 0, + FeeRatePpm: 0, + TimeLockDelta: 80, + } + b.bob.RPC.UpdateChannelPolicy(bobUpdateReq) + + const paymentAmt = 10_000_000 + blindedPath := b.buildBlindedPathWithNextNodeID(paymentAmt) + route := b.createRouteToBlinded(paymentAmt, blindedPath) + + hash := sha256.Sum256(b.preimage[:]) + sendReq := &routerrpc.SendToRouteRequest{ + PaymentHash: hash[:], + Route: route, + } + + // Dispatch the payment in the background since the HTLC will be held by + // Carol's interceptor until we resolve it. + done := make(chan struct{}) + go func() { + defer close(done) + + htlcAttempt, err := b.alice.RPC.Router.SendToRouteV2( + ctx, sendReq, + ) + require.NoError(ht, err) + require.Equal( + ht, lnrpc.HTLCAttempt_SUCCEEDED, htlcAttempt.Status, + ) + }() + + // Bob holding two active HTLCs (one incoming from Alice, one outgoing + // to Carol) demonstrates that Bob (the lnd introduction node) resolved + // Carol's node ID and forwarded the HTLC onwards. We assert on the + // count rather than a specific Bob -> Carol channel because non-strict + // forwarding may pick any of Bob's channels to Carol. + ht.AssertOutgoingHTLCActive(b.alice, b.channels[0], hash[:]) + ht.AssertNumActiveHtlcs(b.bob, 2) + + // Carol intercepts the forwarded HTLC, confirming that the introduction + // node's resolution and forwarding succeeded. Settle it with the + // preimage so that Alice's payment completes successfully. + interceptor := b.carolInterceptor + carolHTLC, err := interceptor.Recv() + require.NoError(ht, err) + + // Carol's own onward hop to Dave is also identified by node ID, so her + // intercept request must expose Dave's pubkey and flag the node-ID + // forward with the sentinel outgoing channel rather than a zero SCID. + require.Equal( + ht, htlcswitch.NodeIDForwardSCID, + carolHTLC.OutgoingRequestedChanId, + ) + require.Equal(ht, b.dave.PubKey[:], carolHTLC.OutgoingRequestedNodeId) + + // Run any caller-supplied step while the HTLC is held mid-flight. + if midFlight != nil { + midFlight() + } + + err = interceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: carolHTLC.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_SETTLE, + Preimage: b.preimage[:], + }) + require.NoError(ht, err) + + select { + case <-done: + case <-time.After(defaultTimeout): + require.Fail(ht, "timeout waiting for payment to complete") + } +} + // testPartiallySpecifiedBlindedPath tests lnd's ability to: // - Assert the error when attempting to create a blinded payment with an // invalid partially specified path.