diff --git a/assets/client.go b/assets/client.go index 6b8b514f..2d963bb0 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 8fa79092..c23ab7f2 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