From 34cf98e8e800e45ef00ddcf972b5ffc779a5042d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 8 Jul 2026 23:38:03 -0300 Subject: [PATCH] loopd: map static address loop-ins to swap status --- interface.go | 14 +- loopd/swapclient_server.go | 174 ++++++++++++++++++++++++- loopd/swapclient_server_test.go | 218 ++++++++++++++++++++++++++++++++ swap/type.go | 9 +- 4 files changed, 409 insertions(+), 6 deletions(-) diff --git a/interface.go b/interface.go index 10a86bfa..4b8623bf 100644 --- a/interface.go +++ b/interface.go @@ -4,6 +4,7 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/taproot-assets/rfqmath" @@ -476,8 +477,12 @@ type SwapInfoKit struct { LastUpdateTime time.Time } -// SwapInfo exposes common info fields for loop in and loop out swaps. +// SwapInfo exposes common info fields for traditional swaps and static address +// loop-ins. type SwapInfo struct { + // SwapStateData.State is authoritative for swap.TypeIn and swap.TypeOut. + // For swap.TypeStaticAddressLoopIn, StaticAddressLoopInState is + // authoritative and State remains its zero value, loopdb.StateInitiated. loopdb.SwapStateData loopdb.SwapContract @@ -488,9 +493,14 @@ type SwapInfo struct { // SwapHash stores the swap preimage hash. SwapHash lntypes.Hash - // SwapType describes whether this is a loop in or loop out swap. + // SwapType describes the kind of swap. SwapType swap.Type + // StaticAddressLoopInState stores the precise static address loop-in FSM + // state when SwapType is swap.TypeStaticAddressLoopIn. For traditional + // swaps, it remains the fsm.StateType zero value, fsm.EmptyState. + StaticAddressLoopInState fsm.StateType + // HtlcAddressP2WSH stores the address of the P2WSH (native segwit) // swap htlc. This is used for both loop-in and loop-out. HtlcAddressP2WSH btcutil.Address diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d89dbbce..a76bc32b 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -411,6 +411,8 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, } var swapType looprpc.SwapType + staticLoopInState := looprpc. + StaticAddressLoopInSwapState_UNKNOWN_STATIC_ADDRESS_SWAP_STATE var ( htlcAddress string htlcAddressP2TR string @@ -437,6 +439,27 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, lastHop = loopSwap.LastHop[:] } + case swap.TypeStaticAddressLoopIn: + // Static loop-ins surface their precise FSM state through the + // optional oneof and keep the reconstructed HTLC P2WSH address, + // not the reusable static address. + swapType = looprpc.SwapType_STATIC_LOOP_IN + staticLoopInState = toClientStaticAddressLoopInState( + loopSwap.StaticAddressLoopInState, + ) + + if loopSwap.HtlcAddressP2WSH == nil { + return nil, errors.New( + "missing static address loop-in P2WSH HTLC address", + ) + } + htlcAddressP2WSH = loopSwap.HtlcAddressP2WSH.EncodeAddress() + htlcAddress = htlcAddressP2WSH + + if loopSwap.LastHop != nil { + lastHop = loopSwap.LastHop[:] + } + case swap.TypeOut: swapType = looprpc.SwapType_LOOP_OUT if loopSwap.HtlcAddressP2WSH != nil { @@ -478,7 +501,7 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, return nil, errors.New("unknown swap type") } - return &looprpc.SwapStatus{ + rpcSwap := &looprpc.SwapStatus{ Amt: int64(loopSwap.AmountRequested), Id: loopSwap.SwapHash.String(), IdBytes: loopSwap.SwapHash[:], @@ -497,7 +520,15 @@ func (s *swapClientServer) marshallSwap(ctx context.Context, LastHop: lastHop, OutgoingChanSet: outGoingChanSet, AssetInfo: assetInfo, - }, nil + } + if swapType == looprpc.SwapType_STATIC_LOOP_IN { + rpcSwap.StaticLoopInStateOptional = + &looprpc.SwapStatus_StaticLoopInState{ + StaticLoopInState: staticLoopInState, + } + } + + return rpcSwap, nil } // Monitor will return a stream of swap updates for currently active swaps. @@ -2089,6 +2120,8 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, }, nil } +// staticAddressLoopInTimestamp converts a non-zero timestamp to Unix nano +// form and preserves zero timestamps as zero. func staticAddressLoopInTimestamp(t time.Time) int64 { if t.IsZero() { return 0 @@ -2114,6 +2147,141 @@ func staticAddressLoopInSwapServerCost(swp *loopin.StaticAddressLoopIn) int64 { } } +// staticAddressLoopInSwapInfos loads the static-address loop-in manager swaps +// and converts them to client-facing swap info records. +func (s *swapClientServer) staticAddressLoopInSwapInfos( + ctx context.Context) ([]*loop.SwapInfo, error) { + + if s.staticLoopInManager == nil { + return nil, nil + } + + staticSwaps, err := s.staticLoopInManager.GetAllSwaps(ctx) + if err != nil { + return nil, err + } + + swapInfos := make([]*loop.SwapInfo, 0, len(staticSwaps)) + for _, swp := range staticSwaps { + if swp == nil { + continue + } + + swapInfo, err := s.staticAddressLoopInSwapInfo(ctx, swp) + if err != nil { + return nil, err + } + swapInfos = append(swapInfos, swapInfo) + } + + return swapInfos, nil +} + +// staticAddressLoopInSwapInfo converts one static-address loop-in into swap +// info using the daemon's current chain parameters. +func (s *swapClientServer) staticAddressLoopInSwapInfo(_ context.Context, + swp *loopin.StaticAddressLoopIn) (*loop.SwapInfo, error) { + + chainParams, err := s.network.ChainParams() + if err != nil { + return nil, fmt.Errorf("error getting chain params") + } + + return staticAddressLoopInSwapInfoWithChainParams(swp, chainParams) +} + +// staticAddressLoopInSwapInfoWithChainParams converts one static-address +// loop-in into swap info, including its reconstructed V2 P2WSH HTLC address. +func staticAddressLoopInSwapInfoWithChainParams( + swp *loopin.StaticAddressLoopIn, + chainParams *chaincfg.Params) (*loop.SwapInfo, error) { + + htlcAddress, err := staticAddressLoopInHtlcAddress(swp, chainParams) + if err != nil { + return nil, err + } + + var lastHop *route.Vertex + if len(swp.LastHop) > 0 { + vertex, err := route.NewVertexFromBytes(swp.LastHop) + if err != nil { + return nil, err + } + lastHop = &vertex + } + + amount := swp.TotalDepositAmount() + if swp.SelectedAmount > 0 { + amount = swp.SelectedAmount + } + + lastUpdate := swp.LastUpdateTime + if lastUpdate.IsZero() { + lastUpdate = swp.InitiationTime + } + + return &loop.SwapInfo{ + SwapStateData: loopdb.SwapStateData{ + // Mirror ListStaticAddressSwaps by reporting only the persisted + // client-visible server cost. On-chain and off-chain costs stay + // zero until static loop-ins persist real fee data. + Cost: loopdb.SwapCost{ + Server: btcutil.Amount( + staticAddressLoopInSwapServerCost(swp), + ), + }, + }, + SwapContract: loopdb.SwapContract{ + AmountRequested: amount, + CltvExpiry: swp.HtlcCltvExpiry, + MaxSwapFee: swp.MaxSwapFee, + InitiationTime: swp.InitiationTime, + Label: swp.Label, + ProtocolVersion: loopdb.ProtocolVersion( + swp.ProtocolVersion, + ), + }, + LastUpdate: lastUpdate, + SwapHash: swp.SwapHash, + SwapType: swap.TypeStaticAddressLoopIn, + StaticAddressLoopInState: swp.GetState(), + HtlcAddressP2WSH: htlcAddress, + LastHop: lastHop, + }, nil +} + +// staticAddressLoopInHtlcAddress reconstructs the V2 P2WSH HTLC address from +// the static-address loop-in's client and server keys. +func staticAddressLoopInHtlcAddress(swp *loopin.StaticAddressLoopIn, + chainParams *chaincfg.Params) (btcutil.Address, error) { + + if swp.ClientPubkey == nil { + return nil, errors.New("missing static address loop-in client HTLC key") + } + if swp.ServerPubkey == nil { + return nil, errors.New("missing static address loop-in server HTLC key") + } + + htlc, err := swap.NewHtlcV2( + swp.HtlcCltvExpiry, pubkeyTo33ByteSlice(swp.ClientPubkey), + pubkeyTo33ByteSlice(swp.ServerPubkey), swp.SwapHash, chainParams, + ) + if err != nil { + return nil, fmt.Errorf("construct static address loop-in HTLC: %w", err) + } + + return htlc.Address, nil +} + +// pubkeyTo33ByteSlice converts a compressed public key to a fixed 33-byte +// array. +func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte { + var pubkeyBytes [33]byte + copy(pubkeyBytes[:], pubkey.SerializeCompressed()) + + return pubkeyBytes +} + // GetStaticAddressSummary returns a summary of static address-related // information. Amongst deposits and withdrawals and their total values, it also // includes a list of detailed deposit information filtered by their state. @@ -2420,6 +2588,8 @@ func toClientDepositState(state fsm.StateType) looprpc.DepositState { } } +// toClientStaticAddressLoopInState maps the static-address loop-in FSM state +// to the RPC enum exposed to clients. func toClientStaticAddressLoopInState( state fsm.StateType) looprpc.StaticAddressLoopInSwapState { diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index f3fab032..ac516f76 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -25,6 +26,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" mock_lnd "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" @@ -501,6 +503,222 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { ) } +// TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH protects the RPC +// mapping invariant that static loop-ins expose their static type, static +// state, and P2WSH HTLC address without leaking a taproot HTLC address. +func TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH(t *testing.T) { + server := &swapClientServer{} + loopSwap := &loop.SwapInfo{ + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateInitiated, + }, + SwapContract: loopdb.SwapContract{ + InitiationTime: time.Now(), + }, + LastUpdate: time.Now(), + SwapHash: lntypes.Hash{1}, + SwapType: swap.TypeStaticAddressLoopIn, + StaticAddressLoopInState: loopin.SignHtlcTx, + HtlcAddressP2WSH: testnetAddr, + } + + rpcSwap, err := server.marshallSwap(t.Context(), loopSwap) + require.NoError(t, err) + require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, rpcSwap.Type) + require.Equal( + t, looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX, + rpcSwap.GetStaticLoopInState(), + ) + require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State) + require.Equal(t, testnetAddr.EncodeAddress(), rpcSwap.HtlcAddressP2Wsh) + require.Empty(t, rpcSwap.HtlcAddressP2Tr) +} + +// TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault asserts that +// static loop-in failures keep default legacy fields while preserving the +// precise static state. +func TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault( + t *testing.T) { + + tests := []struct { + name string + state fsm.StateType + wantStaticState looprpc.StaticAddressLoopInSwapState + }{ + { + name: "failed", + state: loopin.Failed, + wantStaticState: looprpc. + StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP, + }, + { + name: "succeeded transitioning failed", + state: loopin.SucceededTransitioningFailed, + wantStaticState: looprpc. + StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server, staticLoopIn := newGenericStaticLoopInServer(t) + staticLoopIn.SetState(test.state) + loopSwap, err := server.staticAddressLoopInSwapInfo( + t.Context(), staticLoopIn, + ) + require.NoError(t, err) + + rpcSwap, err := server.marshallSwap(t.Context(), loopSwap) + + require.NoError(t, err) + require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State) + require.Equal( + t, looprpc.FailureReason_FAILURE_REASON_NONE, + rpcSwap.FailureReason, + ) + require.Equal( + t, test.wantStaticState, + rpcSwap.GetStaticLoopInState(), + ) + }) + } +} + +// TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress protects the +// fail-closed HTLC-address invariant for static loop-ins missing the P2WSH +// address required by the client-facing RPC representation. +func TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress(t *testing.T) { + _, taprootAddress := newTestStaticAddressParams(t) + server := &swapClientServer{} + loopSwap := &loop.SwapInfo{ + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateInitiated, + }, + SwapContract: loopdb.SwapContract{ + InitiationTime: time.Now(), + }, + LastUpdate: time.Now(), + SwapHash: lntypes.Hash{1}, + SwapType: swap.TypeStaticAddressLoopIn, + StaticAddressLoopInState: loopin.SignHtlcTx, + HtlcAddressP2TR: taprootAddress, + } + + _, err := server.marshallSwap(t.Context(), loopSwap) + require.ErrorContains(t, err, "missing static address loop-in P2WSH HTLC address") +} + +// TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing protects the +// HTLC-address construction invariant that missing cooperative keys must not +// produce monitorable swap info. +func TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing(t *testing.T) { + server, staticLoopIn := newGenericStaticLoopInServer(t) + staticLoopIn.ClientPubkey = nil + + _, err := server.staticAddressLoopInSwapInfo(t.Context(), staticLoopIn) + require.ErrorContains( + t, err, "missing static address loop-in client HTLC key", + ) +} + +func newGenericStaticLoopInServer(t *testing.T) (*swapClientServer, + *loopin.StaticAddressLoopIn) { + + server, staticLoopIn, _ := newGenericStaticLoopInServerWithStore(t) + + return server, staticLoopIn +} + +func newTestStaticAddressParams(t *testing.T) (*script.Parameters, + *btcutil.AddressTaproot) { + + t.Helper() + + const staticAddressExpiry = uint32(25) + + _, staticClientPubkey := mock_lnd.CreateKey(12) + _, staticServerPubkey := mock_lnd.CreateKey(13) + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(staticAddressExpiry), + staticClientPubkey, staticServerPubkey, + ) + require.NoError(t, err) + + staticPkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + taprootAddress, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(staticAddress.TaprootKey), + &chaincfg.TestNet3Params, + ) + require.NoError(t, err) + + return &script.Parameters{ + ClientPubkey: staticClientPubkey, + ServerPubkey: staticServerPubkey, + Expiry: staticAddressExpiry, + PkScript: staticPkScript, + }, taprootAddress +} + +func newGenericStaticLoopInServerWithStore(t *testing.T) (*swapClientServer, + *loopin.StaticAddressLoopIn, *mockStaticAddressLoopInStore) { + + t.Helper() + + _, clientPubkey := mock_lnd.CreateKey(10) + _, serverPubkey := mock_lnd.CreateKey(11) + addressParams, _ := newTestStaticAddressParams(t) + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{12, 13, 14}, + Index: 2, + } + staticDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + Value: 51_000, + } + lastHop := route.Vertex{7, 8, 9} + + staticLoopIn := &loopin.StaticAddressLoopIn{ + SwapHash: lntypes.Hash{1, 2, 3}, + HtlcCltvExpiry: 700, + InitiationTime: time.Unix(100, 0).UTC(), + LastUpdateTime: time.Unix(200, 0).UTC(), + Label: "static-loop-in", + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + LastHop: lastHop[:], + QuotedSwapFee: 1_111, + SelectedAmount: 50_000, + DepositOutpoints: []string{depositOutpoint.String()}, + Deposits: []*deposit.Deposit{staticDeposit}, + AddressParams: addressParams, + } + staticLoopIn.SetState(loopin.PaymentReceived) + + depositStore := &mockDepositStore{ + byOutpoint: map[string]*deposit.Deposit{ + depositOutpoint.String(): staticDeposit, + }, + } + loopInStore := &mockStaticAddressLoopInStore{ + swaps: []*loopin.StaticAddressLoopIn{staticLoopIn}, + } + staticLoopInManager, err := loopin.NewManager(&loopin.Config{ + Store: loopInStore, + DepositManager: deposit.NewManager(&deposit.ManagerConfig{ + Store: depositStore, + }), + }, 1) + require.NoError(t, err) + + return &swapClientServer{ + network: lndclient.NetworkTestnet, + swaps: make(map[lntypes.Hash]loop.SwapInfo), + staticLoopInManager: staticLoopInManager, + }, staticLoopIn, loopInStore +} + // mockStaticAddressLoopInStore is a minimal in-memory loop-in store for RPC // response mapping tests. type mockStaticAddressLoopInStore struct { diff --git a/swap/type.go b/swap/type.go index 20942c74..b4558e15 100644 --- a/swap/type.go +++ b/swap/type.go @@ -9,10 +9,13 @@ const ( // TypeOut is a loop out swap. TypeOut + + // TypeStaticAddressLoopIn is a static-address loop-in swap. + TypeStaticAddressLoopIn ) -// IsOut returns true if the swap is a loop out swap, false if it is a loop in -// swap. +// IsOut returns true only if the swap is TypeOut; TypeIn and +// TypeStaticAddressLoopIn both return false. func (t Type) IsOut() bool { return t == TypeOut } @@ -23,6 +26,8 @@ func (t Type) String() string { return "In" case TypeOut: return "Out" + case TypeStaticAddressLoopIn: + return "StaticAddressLoopIn" default: return "Unknown" }