From 514c3f06ada69f5c6ae6395f84bfcc95a0a0882b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:16:37 +0200 Subject: [PATCH 1/6] loopd: close tapd client connections Close the TapdClient when daemon initialization fails, during normal shutdown, and after the view command completes. This prevents gRPC transport resources from leaking across embedded daemon lifecycles and error paths. --- loopd/daemon.go | 21 +++++++++++++++++---- loopd/view.go | 1 + 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/loopd/daemon.go b/loopd/daemon.go index f625aa270..319709d84 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -158,6 +158,15 @@ func (d *Daemon) Start() error { if err != nil { return err } + + defer func() { + if err == nil || d.assetClient == nil { + return + } + + d.assetClient.Close() + d.assetClient = nil + }() } // With lnd connected, initialize everything else, such as the swap @@ -178,15 +187,15 @@ func (d *Daemon) Start() error { // If we get here, we already have started several goroutines. So if // anything goes wrong now, we need to cleanly shut down again. - startErr := d.startWebServers() - if startErr != nil { - errorf("Error while starting daemon: %v", startErr) + err = d.startWebServers() + if err != nil { + errorf("Error while starting daemon: %v", err) d.Stop() stopErr := <-d.ErrChan if stopErr != nil { errorf("Error while stopping daemon: %v", stopErr) } - return startErr + return err } return nil @@ -1145,6 +1154,10 @@ func (d *Daemon) stop() { if d.clientCleanup != nil { d.clientCleanup() } + if d.assetClient != nil { + d.assetClient.Close() + d.assetClient = nil + } // Everything should be shutting down now, wait for completion. d.wg.Wait() diff --git a/loopd/view.go b/loopd/view.go index 73b401d86..86b9604a1 100644 --- a/loopd/view.go +++ b/loopd/view.go @@ -44,6 +44,7 @@ func view(config *Config, lisCfg *ListenerCfg) error { if err != nil { return err } + defer assetClient.Close() } swapClient, cleanup, err := getClient( From c7d5e466cdd2f05ea1a32c986008f1f6dd934f86 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:17:25 +0200 Subject: [PATCH 2/6] assets: validate RFQ timeout conversion Convert the configured duration once during client creation. Round positive fractional durations up to the whole seconds accepted by tapd. Reject zero, negative, and overflowing values, and cover the conversion boundaries with unit tests. --- assets/client.go | 40 +++++++++++++++++++++++------ assets/client_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/assets/client.go b/assets/client.go index 6b8b514f7..2d963bb09 100644 --- a/assets/client.go +++ b/assets/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "math" "os" "path/filepath" "sync" @@ -78,14 +79,19 @@ type TapdClient struct { rfqrpc.RfqClient universerpc.UniverseClient - cfg *TapdConfig - assetNameCache map[string]string - assetNameMutex sync.Mutex - cc *grpc.ClientConn + rfqTimeoutSeconds uint32 + assetNameCache map[string]string + assetNameMutex sync.Mutex + cc *grpc.ClientConn } // NewTapdClient returns a new taproot assets client. func NewTapdClient(config *TapdConfig) (*TapdClient, error) { + rfqTimeoutSeconds, err := getRfqTimeoutSeconds(config.RFQtimeout) + if err != nil { + return nil, err + } + // Create the client connection to the server. conn, err := getClientConn(config) if err != nil { @@ -96,7 +102,7 @@ func NewTapdClient(config *TapdConfig) (*TapdClient, error) { client := &TapdClient{ assetNameCache: make(map[string]string), cc: conn, - cfg: config, + rfqTimeoutSeconds: rfqTimeoutSeconds, TaprootAssetsClient: taprpc.NewTaprootAssetsClient(conn), TaprootAssetChannelsClient: tapchannelrpc.NewTaprootAssetChannelsClient(conn), PriceOracleClient: priceoraclerpc.NewPriceOracleClient(conn), @@ -139,7 +145,7 @@ func (c *TapdClient) GetRfqForAsset(ctx context.Context, PeerPubKey: peerPubkey, PaymentMaxAmt: uint64(paymentMaxAmt), Expiry: uint64(expiry), - TimeoutSeconds: uint32(c.cfg.RFQtimeout.Seconds()), + TimeoutSeconds: c.rfqTimeoutSeconds, }) if err != nil { return nil, err @@ -220,7 +226,7 @@ func (c *TapdClient) GetAssetPrice(ctx context.Context, assetID string, }, PaymentMaxAmt: uint64(msatAmt), Expiry: uint64(rfqExpiry), - TimeoutSeconds: uint32(c.cfg.RFQtimeout.Seconds()), + TimeoutSeconds: c.rfqTimeoutSeconds, PeerPubKey: peerPubkey, }) if err != nil { @@ -288,6 +294,26 @@ func getPaymentMaxAmount(satAmount btcutil.Amount, feeLimitMultiplier float64) ( ) } +// getRfqTimeoutSeconds converts the configured RFQ timeout to the whole +// seconds accepted by tapd. Fractional seconds are rounded up so tapd's +// timeout is never shorter than the configured duration. +func getRfqTimeoutSeconds(timeout time.Duration) (uint32, error) { + if timeout <= 0 { + return 0, fmt.Errorf("RFQ timeout must be greater than zero") + } + + seconds := timeout / time.Second + if timeout%time.Second != 0 { + seconds++ + } + if seconds > time.Duration(math.MaxUint32) { + return 0, fmt.Errorf("RFQ timeout exceeds maximum of %v seconds", + uint64(math.MaxUint32)) + } + + return uint32(seconds), nil +} + func getClientConn(config *TapdConfig) (*grpc.ClientConn, error) { // Load the specified TLS certificate and build transport credentials. creds, err := credentials.NewClientTLSFromFile(config.TLSPath, "") diff --git a/assets/client_test.go b/assets/client_test.go index 8fa79092d..c23ab7f2a 100644 --- a/assets/client_test.go +++ b/assets/client_test.go @@ -2,11 +2,13 @@ package assets import ( "encoding/pem" + "math" "net/http" "net/http/httptest" "os" "path/filepath" "testing" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" @@ -141,6 +143,62 @@ func TestGetPaymentMaxAmount(t *testing.T) { } } +// TestGetRfqTimeoutSeconds verifies that configured durations are safely +// converted to tapd's whole-second timeout field. +func TestGetRfqTimeoutSeconds(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + expectedSeconds uint32 + expectError bool + }{ + { + name: "whole seconds", + timeout: 60 * time.Second, + expectedSeconds: 60, + }, + { + name: "sub-second rounded up", + timeout: time.Millisecond, + expectedSeconds: 1, + }, + { + name: "fractional second rounded up", + timeout: time.Second + time.Nanosecond, + expectedSeconds: 2, + }, + { + name: "zero", + timeout: 0, + expectError: true, + }, + { + name: "negative", + timeout: -time.Second, + expectError: true, + }, + { + name: "overflow", + timeout: time.Duration(math.MaxUint32)*time.Second + + time.Nanosecond, + expectError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + seconds, err := getRfqTimeoutSeconds(test.timeout) + if test.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, test.expectedSeconds, seconds) + }) + } +} + func TestGetSatsFromAssetAmt(t *testing.T) { tests := []struct { assetAmt uint64 From dc39d63d8db4f8a1b919b771eaa9c53bdddb9208 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:19:02 +0200 Subject: [PATCH 3/6] assets: avoid locking cache during RPC Restrict the asset-name cache mutex to map access so a slow QueryAssetStats call cannot block cached readers. Use an RWMutex for independent cache reads and add a concurrent regression test. --- assets/client.go | 25 ++++++++++--- assets/client_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/assets/client.go b/assets/client.go index 2d963bb09..ef320af8f 100644 --- a/assets/client.go +++ b/assets/client.go @@ -81,7 +81,7 @@ type TapdClient struct { rfqTimeoutSeconds uint32 assetNameCache map[string]string - assetNameMutex sync.Mutex + assetNameMutex sync.RWMutex cc *grpc.ClientConn } @@ -169,10 +169,8 @@ func (c *TapdClient) GetRfqForAsset(ctx context.Context, func (c *TapdClient) GetAssetName(ctx context.Context, assetId []byte) (string, error) { - c.assetNameMutex.Lock() - defer c.assetNameMutex.Unlock() assetIdStr := hex.EncodeToString(assetId) - if name, ok := c.assetNameCache[assetIdStr]; ok { + if name, ok := c.getCachedAssetName(assetIdStr); ok { return name, nil } @@ -198,11 +196,28 @@ func (c *TapdClient) GetAssetName(ctx context.Context, assetName = assetStats.AssetStats[0].Asset.AssetName } - c.assetNameCache[assetIdStr] = assetName + c.cacheAssetName(assetIdStr, assetName) return assetName, nil } +// getCachedAssetName returns an asset name from the cache. +func (c *TapdClient) getCachedAssetName(assetID string) (string, bool) { + c.assetNameMutex.RLock() + defer c.assetNameMutex.RUnlock() + + name, ok := c.assetNameCache[assetID] + return name, ok +} + +// cacheAssetName adds an asset name to the cache. +func (c *TapdClient) cacheAssetName(assetID, name string) { + c.assetNameMutex.Lock() + defer c.assetNameMutex.Unlock() + + c.assetNameCache[assetID] = name +} + // GetAssetPrice returns the price of an asset in satoshis. NOTE: this currently // uses the rfq process for the asset price. A future implementation should // use a price oracle to not spam a peer. diff --git a/assets/client_test.go b/assets/client_test.go index c23ab7f2a..5fd2b2e1e 100644 --- a/assets/client_test.go +++ b/assets/client_test.go @@ -1,6 +1,8 @@ package assets import ( + "context" + "encoding/hex" "encoding/pem" "math" "net/http" @@ -12,11 +14,38 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightninglabs/taproot-assets/taprpc/universerpc" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "gopkg.in/macaroon.v2" ) +type blockingUniverseClient struct { + universerpc.UniverseClient + + queryStarted chan struct{} + releaseQuery chan struct{} +} + +func (b *blockingUniverseClient) QueryAssetStats(context.Context, + *universerpc.AssetStatsQuery, ...grpc.CallOption) ( + *universerpc.UniverseAssetStats, error) { + + close(b.queryStarted) + <-b.releaseQuery + + return &universerpc.UniverseAssetStats{ + AssetStats: []*universerpc.AssetStatsSnapshot{ + { + Asset: &universerpc.AssetStatsAsset{ + AssetName: "queried asset", + }, + }, + }, + }, nil +} + // TestDefaultTapdConfig tests that the default tapd connection paths match // tapd's mainnet defaults. func TestDefaultTapdConfig(t *testing.T) { @@ -84,6 +113,61 @@ func TestTapdConfigClientConn(t *testing.T) { ) } +// TestGetAssetNameCachedLookupNotBlocked verifies that a slow universe query +// for one asset does not prevent another caller from reading a cached name. +func TestGetAssetNameCachedLookupNotBlocked(t *testing.T) { + const cachedName = "cached asset" + + cachedAssetID := []byte{1} + queryStarted := make(chan struct{}) + releaseQuery := make(chan struct{}) + client := &TapdClient{ + UniverseClient: &blockingUniverseClient{ + queryStarted: queryStarted, + releaseQuery: releaseQuery, + }, + assetNameCache: map[string]string{ + hex.EncodeToString(cachedAssetID): cachedName, + }, + } + + queryResult := make(chan error, 1) + go func() { + _, err := client.GetAssetName(context.Background(), []byte{2}) + queryResult <- err + }() + + select { + case <-queryStarted: + case <-time.After(time.Second): + t.Fatal("universe query did not start") + } + + type nameResult struct { + name string + err error + } + cachedResult := make(chan nameResult, 1) + go func() { + name, err := client.GetAssetName( + context.Background(), cachedAssetID, + ) + cachedResult <- nameResult{name: name, err: err} + }() + + select { + case result := <-cachedResult: + require.NoError(t, result.err) + require.Equal(t, cachedName, result.name) + case <-time.After(time.Second): + close(releaseQuery) + t.Fatal("cached lookup blocked behind universe query") + } + + close(releaseQuery) + require.NoError(t, <-queryResult) +} + func TestGetPaymentMaxAmount(t *testing.T) { tests := []struct { satAmount btcutil.Amount From cfbade12394c4c6b6434b0d627da4ff90f2ce0cb Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:19:37 +0200 Subject: [PATCH 4/6] assets: reject malformed RFQ asset rates Validate the rate pointer and decimal coefficient before converting asset units. Return errors for nil, malformed, non-positive, and oversized-scale rates instead of allowing nil dereferences or division-by-zero panics. Add regression tests for each case. --- assets/client.go | 31 +++++++++++++++++++++++++++++-- assets/client_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/assets/client.go b/assets/client.go index ef320af8f..39090afc0 100644 --- a/assets/client.go +++ b/assets/client.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "math" + "math/big" "os" "path/filepath" "sync" @@ -12,7 +13,6 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/taproot-assets/rfqmath" - "github.com/lightninglabs/taproot-assets/rpcutils" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc" "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" @@ -275,7 +275,7 @@ func (c *TapdClient) GetAssetPrice(ctx context.Context, assetID string, func getSatsFromAssetAmt(assetAmt uint64, assetRate *rfqrpc.FixedPoint) ( btcutil.Amount, error) { - rateFP, err := rpcutils.UnmarshalRfqFixedPoint(assetRate) + rateFP, err := unmarshalAssetRate(assetRate) if err != nil { return 0, fmt.Errorf("cannot unmarshal asset rate: %w", err) } @@ -287,6 +287,33 @@ func getSatsFromAssetAmt(assetAmt uint64, assetRate *rfqrpc.FixedPoint) ( return msatAmt.ToSatoshis(), nil } +// unmarshalAssetRate validates and converts an RPC asset rate to the fixed +// point representation used for RFQ arithmetic. +func unmarshalAssetRate(assetRate *rfqrpc.FixedPoint) ( + *rfqmath.BigIntFixedPoint, error) { + + if assetRate == nil { + return nil, fmt.Errorf("asset rate cannot be nil") + } + if assetRate.Scale > math.MaxUint8 { + return nil, fmt.Errorf("scale value overflow: %v", assetRate.Scale) + } + + coefficient, ok := new(big.Int).SetString(assetRate.Coefficient, 10) + if !ok { + return nil, fmt.Errorf("invalid asset rate coefficient: %q", + assetRate.Coefficient) + } + if coefficient.Sign() <= 0 { + return nil, fmt.Errorf("asset rate coefficient must be positive") + } + + return &rfqmath.BigIntFixedPoint{ + Coefficient: rfqmath.NewBigInt(coefficient), + Scale: uint8(assetRate.Scale), + }, nil +} + // getPaymentMaxAmount returns the milisat amount we are willing to pay for the // payment. func getPaymentMaxAmount(satAmount btcutil.Amount, feeLimitMultiplier float64) ( diff --git a/assets/client_test.go b/assets/client_test.go index 5fd2b2e1e..af3f671ef 100644 --- a/assets/client_test.go +++ b/assets/client_test.go @@ -308,6 +308,39 @@ func TestGetSatsFromAssetAmt(t *testing.T) { expected: btcutil.Amount(0), expectError: false, }, + { + assetAmt: 1000, + assetRate: nil, + expectError: true, + }, + { + assetAmt: 1000, + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "not-a-number", Scale: 0, + }, + expectError: true, + }, + { + assetAmt: 1000, + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "0", Scale: 0, + }, + expectError: true, + }, + { + assetAmt: 1000, + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "-1", Scale: 0, + }, + expectError: true, + }, + { + assetAmt: 1000, + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "1", Scale: 256, + }, + expectError: true, + }, } for _, test := range tests { From 9e97992e184cd09eba1e7d2d322f06bda5848c3a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 3 Aug 2026 10:01:35 +0200 Subject: [PATCH 5/6] assets: validate accepted RFQ asset rates Validate the bid rate before returning an accepted asset sell quote. This prevents malformed rates from reaching downstream quote arithmetic, where nil or non-positive values can panic. Cover valid and malformed responses with table-driven tests. --- assets/client.go | 13 ++++-- assets/client_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/assets/client.go b/assets/client.go index 39090afc0..7eef98b03 100644 --- a/assets/client.go +++ b/assets/client.go @@ -158,11 +158,18 @@ func (c *TapdClient) GetRfqForAsset(ctx context.Context, rfq.GetRejectedQuote()) } - if rfq.GetAcceptedQuote() != nil { - return rfq.GetAcceptedQuote(), nil + acceptedQuote := rfq.GetAcceptedQuote() + if acceptedQuote == nil { + return nil, fmt.Errorf("no accepted quote") } - return nil, fmt.Errorf("no accepted quote") + _, err = unmarshalAssetRate(acceptedQuote.BidAssetRate) + if err != nil { + return nil, fmt.Errorf("invalid accepted quote asset rate: %w", + err) + } + + return acceptedQuote, nil } // GetAssetName returns the human-readable name of the asset. diff --git a/assets/client_test.go b/assets/client_test.go index af3f671ef..5941ae44d 100644 --- a/assets/client_test.go +++ b/assets/client_test.go @@ -46,6 +46,19 @@ func (b *blockingUniverseClient) QueryAssetStats(context.Context, }, nil } +type staticRfqClient struct { + rfqrpc.RfqClient + + response *rfqrpc.AddAssetSellOrderResponse +} + +func (s *staticRfqClient) AddAssetSellOrder(context.Context, + *rfqrpc.AddAssetSellOrderRequest, ...grpc.CallOption) ( + *rfqrpc.AddAssetSellOrderResponse, error) { + + return s.response, nil +} + // TestDefaultTapdConfig tests that the default tapd connection paths match // tapd's mainnet defaults. func TestDefaultTapdConfig(t *testing.T) { @@ -168,6 +181,89 @@ func TestGetAssetNameCachedLookupNotBlocked(t *testing.T) { require.NoError(t, <-queryResult) } +// TestGetRfqForAssetValidatesRate verifies that malformed accepted quote rates +// are rejected before they reach downstream RFQ arithmetic. +func TestGetRfqForAssetValidatesRate(t *testing.T) { + tests := []struct { + name string + assetRate *rfqrpc.FixedPoint + expectError bool + }{ + { + name: "valid", + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "100000", Scale: 0, + }, + }, + { + name: "nil", + assetRate: nil, + expectError: true, + }, + { + name: "malformed coefficient", + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "not-a-number", Scale: 0, + }, + expectError: true, + }, + { + name: "zero coefficient", + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "0", Scale: 0, + }, + expectError: true, + }, + { + name: "negative coefficient", + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "-1", Scale: 0, + }, + expectError: true, + }, + { + name: "scale overflow", + assetRate: &rfqrpc.FixedPoint{ + Coefficient: "1", Scale: 256, + }, + expectError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + acceptedQuote := &rfqrpc.PeerAcceptedSellQuote{ + BidAssetRate: test.assetRate, + } + acceptedResponse := + &rfqrpc.AddAssetSellOrderResponse_AcceptedQuote{ + AcceptedQuote: acceptedQuote, + } + client := &TapdClient{ + RfqClient: &staticRfqClient{ + response: &rfqrpc.AddAssetSellOrderResponse{ + Response: acceptedResponse, + }, + }, + rfqTimeoutSeconds: 60, + } + + quote, err := client.GetRfqForAsset( + context.Background(), 1000, []byte{1}, []byte{2}, + time.Now().Add(time.Minute).Unix(), 1, + ) + if test.expectError { + require.Error(t, err) + require.Nil(t, quote) + return + } + + require.NoError(t, err) + require.Same(t, acceptedQuote, quote) + }) + } +} + func TestGetPaymentMaxAmount(t *testing.T) { tests := []struct { satAmount btcutil.Amount From b686d97445918e321f61c605c40c2749eb79ebb2 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 3 Aug 2026 10:04:45 +0200 Subject: [PATCH 6/6] docs: note tapd client hardening Document the RFQ validation, cache responsiveness, and tapd connection lifecycle fixes in the rolling release notes. --- docs/release-notes/release-notes-next.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 4de57fdb4..7c9d492e5 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -6,6 +6,11 @@ #### Bug Fixes +* Taproot Asset Loop Out handling now validates RFQ timeouts and asset rates, + keeps cached asset-name lookups responsive during slow `tapd` queries, and + closes `tapd` connections cleanly during shutdown and startup failures. + [PR #1189](https://github.com/lightninglabs/loop/pull/1189) + #### Maintenance #### Contributors (Alphabetical Order)