loopd: map static address loop-ins to swap status

This commit is contained in:
Gustavo Stingelin 2026-07-08 23:38:03 -03:00
parent df7dfba12a
commit 34cf98e8e8
No known key found for this signature in database
GPG key ID: 15CBADFE29F2017B
4 changed files with 409 additions and 6 deletions

View file

@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightninglabs/taproot-assets/rfqmath"
@ -476,8 +477,12 @@ type SwapInfoKit struct {
LastUpdateTime time.Time 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 { 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.SwapStateData
loopdb.SwapContract loopdb.SwapContract
@ -488,9 +493,14 @@ type SwapInfo struct {
// SwapHash stores the swap preimage hash. // SwapHash stores the swap preimage hash.
SwapHash lntypes.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 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) // HtlcAddressP2WSH stores the address of the P2WSH (native segwit)
// swap htlc. This is used for both loop-in and loop-out. // swap htlc. This is used for both loop-in and loop-out.
HtlcAddressP2WSH btcutil.Address HtlcAddressP2WSH btcutil.Address

View file

@ -411,6 +411,8 @@ func (s *swapClientServer) marshallSwap(ctx context.Context,
} }
var swapType looprpc.SwapType var swapType looprpc.SwapType
staticLoopInState := looprpc.
StaticAddressLoopInSwapState_UNKNOWN_STATIC_ADDRESS_SWAP_STATE
var ( var (
htlcAddress string htlcAddress string
htlcAddressP2TR string htlcAddressP2TR string
@ -437,6 +439,27 @@ func (s *swapClientServer) marshallSwap(ctx context.Context,
lastHop = loopSwap.LastHop[:] 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: case swap.TypeOut:
swapType = looprpc.SwapType_LOOP_OUT swapType = looprpc.SwapType_LOOP_OUT
if loopSwap.HtlcAddressP2WSH != nil { if loopSwap.HtlcAddressP2WSH != nil {
@ -478,7 +501,7 @@ func (s *swapClientServer) marshallSwap(ctx context.Context,
return nil, errors.New("unknown swap type") return nil, errors.New("unknown swap type")
} }
return &looprpc.SwapStatus{ rpcSwap := &looprpc.SwapStatus{
Amt: int64(loopSwap.AmountRequested), Amt: int64(loopSwap.AmountRequested),
Id: loopSwap.SwapHash.String(), Id: loopSwap.SwapHash.String(),
IdBytes: loopSwap.SwapHash[:], IdBytes: loopSwap.SwapHash[:],
@ -497,7 +520,15 @@ func (s *swapClientServer) marshallSwap(ctx context.Context,
LastHop: lastHop, LastHop: lastHop,
OutgoingChanSet: outGoingChanSet, OutgoingChanSet: outGoingChanSet,
AssetInfo: assetInfo, 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. // Monitor will return a stream of swap updates for currently active swaps.
@ -2089,6 +2120,8 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
}, nil }, nil
} }
// staticAddressLoopInTimestamp converts a non-zero timestamp to Unix nano
// form and preserves zero timestamps as zero.
func staticAddressLoopInTimestamp(t time.Time) int64 { func staticAddressLoopInTimestamp(t time.Time) int64 {
if t.IsZero() { if t.IsZero() {
return 0 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 // GetStaticAddressSummary returns a summary of static address-related
// information. Amongst deposits and withdrawals and their total values, it also // information. Amongst deposits and withdrawals and their total values, it also
// includes a list of detailed deposit information filtered by their state. // 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( func toClientStaticAddressLoopInState(
state fsm.StateType) looprpc.StaticAddressLoopInSwapState { state fsm.StateType) looprpc.StaticAddressLoopInSwapState {

View file

@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/chaincfg/chainhash"
@ -25,6 +26,7 @@ import (
"github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swap"
mock_lnd "github.com/lightninglabs/loop/test" mock_lnd "github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
"github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet" "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 // mockStaticAddressLoopInStore is a minimal in-memory loop-in store for RPC
// response mapping tests. // response mapping tests.
type mockStaticAddressLoopInStore struct { type mockStaticAddressLoopInStore struct {

View file

@ -9,10 +9,13 @@ const (
// TypeOut is a loop out swap. // TypeOut is a loop out swap.
TypeOut 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 // IsOut returns true only if the swap is TypeOut; TypeIn and
// swap. // TypeStaticAddressLoopIn both return false.
func (t Type) IsOut() bool { func (t Type) IsOut() bool {
return t == TypeOut return t == TypeOut
} }
@ -23,6 +26,8 @@ func (t Type) String() string {
return "In" return "In"
case TypeOut: case TypeOut:
return "Out" return "Out"
case TypeStaticAddressLoopIn:
return "StaticAddressLoopIn"
default: default:
return "Unknown" return "Unknown"
} }