From 562416e99bd24e4a1a4c990362bbac59ac0f8a6b Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 00:09:14 -0500 Subject: [PATCH] loopd: validate static loop-in labels at rpc Move static loop-in label validation to the rpc boundary and remove the same check from the internal manager path. This keeps external requests aligned with the existing swap rpc surface while allowing internal autoloop callers to keep using reserved labels for automated swaps. The tests cover both sides of that contract: rpc requests still reject reserved labels, and the manager path accepts them. --- loopd/swapclient_server.go | 7 +++ loopd/swapclient_server_test.go | 18 ++++++ staticaddr/loopin/manager.go | 7 --- staticaddr/loopin/manager_test.go | 97 ++++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 62187d3e..6229cbeb 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -2097,6 +2097,13 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, Fast: in.Fast, } + // External callers must not be able to use reserved autoloop labels. + // Internal autoloop dispatch bypasses this RPC and can still use the + // reserved labels needed to attribute automated swaps correctly. + if err := labels.Validate(req.Label); err != nil { + return nil, fmt.Errorf("invalid label: %w", err) + } + if in.LastHop != nil { lastHop, err := route.NewVertexFromBytes(in.LastHop) if err != nil { diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index c3877d84..db2d3246 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -262,6 +262,24 @@ func TestValidateLoopInRequest(t *testing.T) { } } +// TestStaticAddressLoopInRejectsReservedLabel verifies that external static +// loop-in requests still reject reserved autoloop labels at the RPC boundary. +func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) { + logger := btclog.NewSLogger( + btclog.NewDefaultHandler(os.Stdout), + ) + setLogger(logger.SubSystem(Subsystem)) + + server := &swapClientServer{} + + _, err := server.StaticAddressLoopIn( + t.Context(), &looprpc.StaticAddressLoopInRequest{ + Label: labels.AutoloopLabel(swap.TypeIn), + }, + ) + require.ErrorContains(t, err, labels.ErrReservedPrefix.Error()) +} + // TestSwapClientServerStopDaemon ensures that calling StopDaemon triggers the // daemon shutdown. func TestSwapClientServerStopDaemon(t *testing.T) { diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 984282e2..a41c7ced 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -19,7 +19,6 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" - "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/staticutil" @@ -692,12 +691,6 @@ func (m *Manager) initiateLoopIn(ctx context.Context, err) } - // Check that the label is valid. - err = labels.Validate(req.Label) - if err != nil { - return nil, fmt.Errorf("invalid label: %w", err) - } - // Private and route hints are mutually exclusive as setting private // means we retrieve our own route hints from the connected node. if len(req.RouteHints) != 0 && req.Private { diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 223f91d6..b9c19d97 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -2,15 +2,21 @@ package loopin import ( "context" + "errors" "testing" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/swap" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) @@ -176,8 +182,46 @@ func TestSelectDeposits(t *testing.T) { } } +// TestInitiateLoopInAllowsReservedAutoloopLabel verifies that the internal +// loop-in manager path does not reject reserved autoloop labels. The RPC +// boundary owns that validation, while internal autoloop dispatch must be able +// to reuse the reserved labels directly. +func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { + ctx := t.Context() + + selectedDeposit := makeDeposit(1, 0, 9_000) + selectedOutpoint := selectedDeposit.OutPoint.String() + quoteErr := errors.New("quote failed") + quoteGetter := &mockQuoteGetter{ + err: quoteErr, + } + + manager, err := NewManager(&Config{ + DepositManager: &mockDepositManager{ + byOutpoint: map[string]*deposit.Deposit{ + selectedOutpoint: selectedDeposit, + }, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + _, err = manager.initiateLoopIn(ctx, &loop.StaticAddressLoopInRequest{ + DepositOutpoints: []string{selectedOutpoint}, + SelectedAmount: selectedDeposit.Value, + MaxSwapFee: 1_000, + Label: labels.AutoloopLabel(swap.TypeIn), + Initiator: "autoloop", + }) + require.ErrorIs(t, err, quoteErr) + require.NotContains(t, err.Error(), labels.ErrReservedPrefix.Error()) + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) +} + // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { + // byOutpoint maps outpoint strings to deposits for direct lookups. byOutpoint map[string]*deposit.Deposit } @@ -187,10 +231,28 @@ func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( return nil, nil } -func (m *mockDepositManager) AllStringOutpointsActiveDeposits(_ []string, - _ fsm.StateType) ([]*deposit.Deposit, bool) { +func (m *mockDepositManager) AllStringOutpointsActiveDeposits(outpoints []string, + state fsm.StateType) ([]*deposit.Deposit, bool) { - return nil, false + if state != deposit.Deposited { + return nil, false + } + + if m.byOutpoint == nil { + return nil, false + } + + res := make([]*deposit.Deposit, 0, len(outpoints)) + for _, outpoint := range outpoints { + selectedDeposit, ok := m.byOutpoint[outpoint] + if !ok { + return nil, false + } + + res = append(res, selectedDeposit) + } + + return res, true } func (m *mockDepositManager) TransitionDeposits(_ context.Context, @@ -217,6 +279,35 @@ func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) ( return nil, nil } +// mockQuoteGetter returns either a configured quote or a configured error and +// records the quoted amount for assertions. +type mockQuoteGetter struct { + // err is the optional error returned from GetLoopInQuote. + err error + + // amount records the quoted amount. + amount btcutil.Amount +} + +// GetLoopInQuote returns the configured quote result for tests. +func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context, + amt btcutil.Amount, _ route.Vertex, lastHop *route.Vertex, + _ [][]zpay32.HopHint, initiator string, numDeposits uint32, + fast bool) (*loop.LoopInQuote, error) { + + m.amount = amt + _ = lastHop + _ = initiator + _ = numDeposits + _ = fast + + if m.err != nil { + return nil, m.err + } + + return &loop.LoopInQuote{}, nil +} + // mockStore implements StaticAddressLoopInStore for tests. type mockStore struct { loopIns map[lntypes.Hash]*StaticAddressLoopIn