diff --git a/assets/client.go b/assets/client.go index 39090afc..7eef98b0 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 af3f671e..5941ae44 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