diff --git a/go.mod b/go.mod index 6da468ead..63e9f6912 100644 --- a/go.mod +++ b/go.mod @@ -231,6 +231,8 @@ replace github.com/lightninglabs/loop/swapserverrpc => ./swapserverrpc replace github.com/lightninglabs/loop/looprpc => ./looprpc +replace github.com/lightninglabs/lndclient => github.com/starius/lndclient v0.20.0-7-addinvoice + // Avoid fetching gonum vanity domains. The domain is unstable and causes // "go mod check" failures in CI. replace gonum.org/v1/gonum => github.com/gonum/gonum v0.11.0 diff --git a/go.sum b/go.sum index 5961e411b..bfbe4abb3 100644 --- a/go.sum +++ b/go.sum @@ -1116,8 +1116,6 @@ github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.4-0.20250610182311-2f1d46ef18b7 h1:373o5lNr1udAdhcf5+zq/0dYpRtkvYLl8Lk6wG7I0DY= github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.4-0.20250610182311-2f1d46ef18b7/go.mod h1:bDnEKRN1u13NFBuy/C+bFLhxA5bfd3clT25y76QY0AM= -github.com/lightninglabs/lndclient v0.20.0-7 h1:EA5QOjT9IJmcgybIuR4pmIXkj2GMpa/2PxOf6j4reWU= -github.com/lightninglabs/lndclient v0.20.0-7/go.mod h1:gBtIFPGmC2xIspGIv/G5+HiPSGJsFD8uIow7Oke1HFI= github.com/lightninglabs/migrate/v4 v4.18.2-9023d66a-fork-pr-2 h1:eFjp1dIB2BhhQp/THKrjLdlYuPugO9UU4kDqu91OX/Q= github.com/lightninglabs/migrate/v4 v4.18.2-9023d66a-fork-pr-2/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/lightninglabs/neutrino v0.16.1 h1:5Kz4ToxncEVkpKC6fwUjXKtFKJhuxlG3sBB3MdJTJjs= @@ -1307,6 +1305,8 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/starius/lndclient v0.20.0-7-addinvoice h1:iv3oudwGpFd1fHLX2mLSoO4joSCQymvVD9PPi+CuWkU= +github.com/starius/lndclient v0.20.0-7-addinvoice/go.mod h1:AQTlloQUUK6OW6j9YRiA/7Sy09PXlyVxsvPo5bW0L6A= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= diff --git a/loopin_test.go b/loopin_test.go index 156f1d601..a1a095845 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/test" "github.com/lightninglabs/loop/utils" "github.com/lightningnetwork/lnd/chainntnfs" @@ -17,6 +18,7 @@ import ( invpkg "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) @@ -124,6 +126,39 @@ func TestLoopInSuccess(t *testing.T) { }) } +// TestLoopInSwapInvoiceRouteHintsMatchProbe asserts that explicit route hints +// are preserved on both loop-in invoices. The probe invoice already keeps the +// requested hints, while the swap invoice currently loses them via the +// lndclient AddInvoice wrapper. +func TestLoopInSwapInvoiceRouteHintsMatchProbe(t *testing.T) { + t.Parallel() + + ctx := newLoopInTestContext(t) + cfg := newSwapConfig( + &ctx.lnd.LndServices, ctx.store, ctx.server, nil, + clock.NewTestClock(time.Unix(123, 0)), + ) + + req := testLoopInRequest + req.RouteHints = testLoopInRouteHints() + + _, err := newLoopInSwap(t.Context(), cfg, 600, &req) + require.NoError(t, err) + + _, swapRouteHints, _, _, err := swap.DecodeInvoice( + ctx.lnd.ChainParams, ctx.server.swapInvoice, + ) + require.NoError(t, err) + + _, probeRouteHints, _, _, err := swap.DecodeInvoice( + ctx.lnd.ChainParams, ctx.server.probeInvoice, + ) + require.NoError(t, err) + + test.RequireRouteHintsEqual(t, req.RouteHints, probeRouteHints) + test.RequireRouteHintsEqual(t, probeRouteHints, swapRouteHints) +} + func testLoopInSuccess(t *testing.T) { defer test.Guard(t)() @@ -233,6 +268,42 @@ func testLoopInSuccess(t *testing.T) { require.NoError(t, <-errChan) } +// testLoopInRouteHints returns deterministic explicit route hints that can be +// encoded into loop-in invoices for regression tests. +func testLoopInRouteHints() [][]zpay32.HopHint { + _, pubKey1 := test.CreateKey(11) + _, pubKey2 := test.CreateKey(12) + _, pubKey3 := test.CreateKey(13) + + return [][]zpay32.HopHint{ + { + { + NodeID: pubKey1, + ChannelID: 1, + FeeBaseMSat: 10, + FeeProportionalMillionths: 20, + CLTVExpiryDelta: 30, + }, + { + NodeID: pubKey2, + ChannelID: 2, + FeeBaseMSat: 11, + FeeProportionalMillionths: 21, + CLTVExpiryDelta: 31, + }, + }, + { + { + NodeID: pubKey3, + ChannelID: 3, + FeeBaseMSat: 12, + FeeProportionalMillionths: 22, + CLTVExpiryDelta: 32, + }, + }, + } +} + // TestLoopInTimeout tests scenarios where the server doesn't sweep the htlc // and the client is forced to reclaim the funds using the timeout tx. func TestLoopInTimeout(t *testing.T) { diff --git a/server_mock_test.go b/server_mock_test.go index 4baceb64b..0e8c029b6 100644 --- a/server_mock_test.go +++ b/server_mock_test.go @@ -40,9 +40,10 @@ type serverMock struct { height int32 - swapInvoice string - swapHash lntypes.Hash - prepayHash lntypes.Hash + swapInvoice string + probeInvoice string + swapHash lntypes.Hash + prepayHash lntypes.Hash // preimagePush is a channel that preimage pushes are sent into. preimagePush chan lntypes.Preimage @@ -157,7 +158,7 @@ func getInvoice(hash lntypes.Hash, amt btcutil.Amount, memo string) (string, err } func (s *serverMock) NewLoopInSwap(_ context.Context, swapHash lntypes.Hash, - amount btcutil.Amount, _, _ [33]byte, swapInvoice, _ string, + amount btcutil.Amount, _, _ [33]byte, swapInvoice, probeInvoice string, _ *route.Vertex, _ string) (*newLoopInResponse, error) { _, receiverKey := test.CreateKey(101) @@ -175,6 +176,7 @@ func (s *serverMock) NewLoopInSwap(_ context.Context, swapHash lntypes.Hash, ) s.swapInvoice = swapInvoice + s.probeInvoice = probeInvoice s.swapHash = swapHash // Simulate the server paying the probe invoice and expect the client to diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 646e9898b..40983e151 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -7,16 +7,22 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" + "google.golang.org/grpc" ) // TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr ensures that an error from @@ -123,6 +129,148 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) { } } +// TestInitHtlcActionPreservesRouteHints asserts that static-address loop-in +// propagates explicit route hints into the encoded swap invoice sent to the +// server. This currently fails because lndclient.AddInvoice drops route hints. +func TestInitHtlcActionPreservesRouteHints(t *testing.T) { + t.Parallel() + + mockLnd := test.NewMockLnd() + _, serverKey := test.CreateKey(21) + + server := &mockStaticAddressServer{ + response: testStaticAddressLoopInResponse( + serverKey.SerializeCompressed(), + ), + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + Value: 500_000, + } + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{dep}, + DepositOutpoints: []string{dep.OutPoint.String()}, + SelectedAmount: dep.Value, + QuotedSwapFee: 1_000, + RouteHints: testStaticAddressRouteHints(), + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: 3_600, + } + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + Server: server, + DepositManager: &noopDepositManager{}, + LndClient: mockLnd.Client, + WalletKit: mockLnd.WalletKit, + ChainParams: mockLnd.ChainParams, + Store: &mockStore{}, + ValidateLoopInContract: testValidateLoopInContract, + MaxStaticAddrHtlcFeePercentage: 1, + MaxStaticAddrHtlcBackupFeePercentage: 1, + }, + loopIn: loopIn, + } + + event := f.InitHtlcAction(t.Context(), nil) + require.Equal(t, OnHtlcInitiated, event) + require.Nil(t, f.LastActionError) + require.NotNil(t, server.request) + + _, routeHints, _, _, err := swap.DecodeInvoice( + mockLnd.ChainParams, server.request.SwapInvoice, + ) + require.NoError(t, err) + + test.RequireRouteHintsEqual(t, loopIn.RouteHints, routeHints) +} + +// mockStaticAddressServer captures static-address loop-in requests in tests. +type mockStaticAddressServer struct { + swapserverrpc.StaticAddressServerClient + + request *swapserverrpc.ServerStaticAddressLoopInRequest + response *swapserverrpc.ServerStaticAddressLoopInResponse +} + +// ServerStaticAddressLoopIn records the request and returns the prepared +// response. +func (m *mockStaticAddressServer) ServerStaticAddressLoopIn( + _ context.Context, in *swapserverrpc.ServerStaticAddressLoopInRequest, + _ ...grpc.CallOption) (*swapserverrpc.ServerStaticAddressLoopInResponse, + error) { + + m.request = in + + return m.response, nil +} + +// testStaticAddressLoopInResponse returns a minimal successful server response +// for InitHtlcAction tests. +func testStaticAddressLoopInResponse( + serverPubKey []byte) *swapserverrpc.ServerStaticAddressLoopInResponse { + + signingInfo := &swapserverrpc.ServerHtlcSigningInfo{ + FeeRate: 1, + } + + return &swapserverrpc.ServerStaticAddressLoopInResponse{ + HtlcServerPubKey: serverPubKey, + HtlcExpiry: 1_000, + StandardHtlcInfo: signingInfo, + HighFeeHtlcInfo: signingInfo, + ExtremeFeeHtlcInfo: signingInfo, + } +} + +// testStaticAddressRouteHints returns deterministic route hints for static +// loop-in invoice regression tests. +func testStaticAddressRouteHints() [][]zpay32.HopHint { + _, pubKey1 := test.CreateKey(31) + _, pubKey2 := test.CreateKey(32) + _, pubKey3 := test.CreateKey(33) + + return [][]zpay32.HopHint{ + { + { + NodeID: pubKey1, + ChannelID: 11, + FeeBaseMSat: 101, + FeeProportionalMillionths: 201, + CLTVExpiryDelta: 31, + }, + { + NodeID: pubKey2, + ChannelID: 12, + FeeBaseMSat: 102, + FeeProportionalMillionths: 202, + CLTVExpiryDelta: 32, + }, + }, + { + { + NodeID: pubKey3, + ChannelID: 13, + FeeBaseMSat: 103, + FeeProportionalMillionths: 203, + CLTVExpiryDelta: 33, + }, + }, + } +} + +// testValidateLoopInContract accepts all server contract parameters in tests. +func testValidateLoopInContract(_ int32, _ int32) error { + return nil +} + // mockAddressManager is a minimal AddressManager implementation used by the // test FSM setup. type mockAddressManager struct { diff --git a/test/invoices_mock.go b/test/invoices_mock.go index 0174a925c..7b0f309dc 100644 --- a/test/invoices_mock.go +++ b/test/invoices_mock.go @@ -83,11 +83,17 @@ func (s *mockInvoices) AddHoldInvoice(ctx context.Context, // Create and encode the payment request as a bech32 (zpay32) string. creationDate := time.Now() - payReq, err := zpay32.NewInvoice( - s.lnd.ChainParams, *hash, creationDate, + options := []func(*zpay32.Invoice){ zpay32.Description(in.Memo), zpay32.CLTVExpiry(in.CltvExpiry), zpay32.Amount(in.Value), + } + for _, routeHint := range in.RouteHints { + options = append(options, zpay32.RouteHint(routeHint)) + } + + payReq, err := zpay32.NewInvoice( + s.lnd.ChainParams, *hash, creationDate, options..., ) if err != nil { return "", err diff --git a/test/lightning_client_mock.go b/test/lightning_client_mock.go index 8732b4446..fe4198a1d 100644 --- a/test/lightning_client_mock.go +++ b/test/lightning_client_mock.go @@ -105,11 +105,17 @@ func (h *mockLightningClient) AddInvoice(ctx context.Context, // Create and encode the payment request as a bech32 (zpay32) string. creationDate := time.Now() - payReq, err := zpay32.NewInvoice( - h.lnd.ChainParams, hash, creationDate, + options := []func(*zpay32.Invoice){ zpay32.Description(in.Memo), zpay32.CLTVExpiry(in.CltvExpiry), zpay32.Amount(in.Value), + } + for _, routeHint := range in.RouteHints { + options = append(options, zpay32.RouteHint(routeHint)) + } + + payReq, err := zpay32.NewInvoice( + h.lnd.ChainParams, hash, creationDate, options..., ) if err != nil { return lntypes.Hash{}, "", err diff --git a/test/route_hints.go b/test/route_hints.go new file mode 100644 index 000000000..bc6434157 --- /dev/null +++ b/test/route_hints.go @@ -0,0 +1,49 @@ +package test + +import ( + "testing" + + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" +) + +// RequireRouteHintsEqual asserts that two route hint sets are identical. +func RequireRouteHintsEqual(t testing.TB, expected, + actual [][]zpay32.HopHint) { + + t.Helper() + + require.Len(t, actual, len(expected)) + + for i := range expected { + require.Len(t, actual[i], len(expected[i])) + + for j := range expected[i] { + expectedHint := expected[i][j] + actualHint := actual[i][j] + + require.Equal( + t, + expectedHint.NodeID.SerializeCompressed(), + actualHint.NodeID.SerializeCompressed(), + ) + require.Equal( + t, expectedHint.ChannelID, actualHint.ChannelID, + ) + require.Equal( + t, expectedHint.FeeBaseMSat, + actualHint.FeeBaseMSat, + ) + require.Equal( + t, + expectedHint.FeeProportionalMillionths, + actualHint.FeeProportionalMillionths, + ) + require.Equal( + t, + expectedHint.CLTVExpiryDelta, + actualHint.CLTVExpiryDelta, + ) + } + } +}