From db25c0ac72aeb7beb12946c3b5834985af6c36ab Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:16:37 +0200 Subject: [PATCH 1/4] 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 | 14 ++++++++++++++ loopd/view.go | 1 + 2 files changed, 15 insertions(+) diff --git a/loopd/daemon.go b/loopd/daemon.go index f625aa270..82e5b2d64 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -166,6 +166,11 @@ func (d *Daemon) Start() error { // and we can just return the error. err = d.initialize(true) if errors.Is(err, bbolterrors.ErrTimeout) { + if d.assetClient != nil { + d.assetClient.Close() + d.assetClient = nil + } + // We're trying to be started as a standalone Loop daemon, most // likely LiT is already running and blocking the DB return fmt.Errorf("%v: make sure no other loop daemon process "+ @@ -173,6 +178,11 @@ func (d *Daemon) Start() error { "running", err) } if err != nil { + if d.assetClient != nil { + d.assetClient.Close() + d.assetClient = nil + } + return err } @@ -1145,6 +1155,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 ae74b2afc645b2f7e99cd65faef06fdcd135ad48 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:17:25 +0200 Subject: [PATCH 2/4] 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..9566b063c 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. Sub-second precision is rounded up so a positive +// timeout can never become tapd's invalid zero value. +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 36f2a8a0fe273e79096c73511f549f3c647ccef3 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:19:02 +0200 Subject: [PATCH 3/4] 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 | 9 +++-- assets/client_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/assets/client.go b/assets/client.go index 9566b063c..05cef474e 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,12 +169,13 @@ 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) + c.assetNameMutex.RLock() if name, ok := c.assetNameCache[assetIdStr]; ok { + c.assetNameMutex.RUnlock() return name, nil } + c.assetNameMutex.RUnlock() assetStats, err := c.UniverseClient.QueryAssetStats( ctx, &universerpc.AssetStatsQuery{ @@ -198,7 +199,9 @@ func (c *TapdClient) GetAssetName(ctx context.Context, assetName = assetStats.AssetStats[0].Asset.AssetName } + c.assetNameMutex.Lock() c.assetNameCache[assetIdStr] = assetName + c.assetNameMutex.Unlock() return assetName, nil } 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 d8910ead98c6a40fd8264cc5ef679310c82a3c33 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 31 Jul 2026 21:19:37 +0200 Subject: [PATCH 4/4] 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 | 14 ++++++++++++++ assets/client_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/assets/client.go b/assets/client.go index 05cef474e..f05e1de95 100644 --- a/assets/client.go +++ b/assets/client.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "math" + "math/big" "os" "path/filepath" "sync" @@ -263,6 +264,19 @@ func (c *TapdClient) GetAssetPrice(ctx context.Context, assetID string, func getSatsFromAssetAmt(assetAmt uint64, assetRate *rfqrpc.FixedPoint) ( btcutil.Amount, error) { + if assetRate == nil { + return 0, fmt.Errorf("asset rate cannot be nil") + } + + coefficient, ok := new(big.Int).SetString(assetRate.Coefficient, 10) + if !ok { + return 0, fmt.Errorf("invalid asset rate coefficient: %q", + assetRate.Coefficient) + } + if coefficient.Sign() <= 0 { + return 0, fmt.Errorf("asset rate coefficient must be positive") + } + rateFP, err := rpcutils.UnmarshalRfqFixedPoint(assetRate) if err != nil { return 0, fmt.Errorf("cannot unmarshal asset rate: %w", err) 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 {