diff --git a/fiat/coincap_api.go b/fiat/coincap_api.go index abca7d6..fcbccdd 100644 --- a/fiat/coincap_api.go +++ b/fiat/coincap_api.go @@ -2,8 +2,13 @@ package fiat import ( "context" + "encoding/json" "errors" + "fmt" + "io/ioutil" + "net/http" "sort" + "strconv" "time" "github.com/lightninglabs/faraday/utils" @@ -13,6 +18,9 @@ const ( // maxQueries is the total number of queries we allow a call to coincap // api to be split up. maxQueries = 5 + + // coinCapHistoryAPI is the endpoint we hit for historical price data. + coinCapHistoryAPI = "https://api.coincap.io/v2/assets/bitcoin/history" ) var ( @@ -100,6 +108,77 @@ type coinCapAPI struct { convert func([]byte) ([]*usdPrice, error) } +// newCoinCapAPI returns a coin cap api struct which can be used to query +// historical prices. +func newCoinCapAPI(granularity Granularity) *coinCapAPI { + return &coinCapAPI{ + granularity: granularity, + query: queryCoinCap, + convert: parseCoinCapData, + } +} + +// queryCoinCap returns a function which will httpQuery coincap for historical +// prices. +func queryCoinCap(start, end time.Time, granularity Granularity) ([]byte, + error) { + + // The coincap api requires milliseconds. + startMs := start.Unix() * 1000 + endMs := end.Unix() * 1000 + url := fmt.Sprintf("%v?interval=%v&start=%v&end=%v", + coinCapHistoryAPI, granularity, startMs, + endMs) + + log.Debugf("coincap url: %v", url) + + // Query the http endpoint with the url provided + // #nosec G107 + response, err := http.Get(url) + if err != nil { + return nil, err + } + defer response.Body.Close() + + return ioutil.ReadAll(response.Body) +} + +type coinCapResponse struct { + Data []*coinCapDataPoint `json:"data"` +} + +type coinCapDataPoint struct { + Price string `json:"priceUsd"` + Timestamp int64 `json:"time"` +} + +// parseCoinCapData parses http response data to usc price structs, using +// intermediary structs to get around parsing. +func parseCoinCapData(data []byte) ([]*usdPrice, error) { + var priceEntries coinCapResponse + if err := json.Unmarshal(data, &priceEntries); err != nil { + return nil, err + } + + var usdRecords = make([]*usdPrice, len(priceEntries.Data)) + + // Convert each entry from the api to a usable record with a converted + // time and parsed price. + for i, entry := range priceEntries.Data { + floatPrice, err := strconv.ParseFloat(entry.Price, 64) + if err != nil { + return nil, err + } + + usdRecords[i] = &usdPrice{ + timestamp: time.Unix(0, entry.Timestamp), + price: floatPrice, + } + } + + return usdRecords, nil +} + // GetPrices retrieves price information from coincap's api. If necessary, this // call splits up the request for data into multiple requests. This is required // because the more granular we want our price data to be, the smaller the diff --git a/fiat/prices.go b/fiat/prices.go new file mode 100644 index 0000000..1ddef33 --- /dev/null +++ b/fiat/prices.go @@ -0,0 +1,144 @@ +package fiat + +import ( + "context" + "errors" + "time" + + "github.com/lightningnetwork/lnd/lnwire" +) + +var ( + errNoPrices = errors.New("no price data provided") + errDuplicateLabel = errors.New("duplicate label in request set") +) + +// PriceRequest describes a request for price information. +type PriceRequest struct { + // Identifier uniquely identifies the request. + Identifier string + + // Value is the amount of BTC in msat. + Value lnwire.MilliSatoshi + + // Timestamp is the time at which the price should be obtained. + Timestamp time.Time +} + +// GetPrices gets a set of prices for a set of timestamped requests. +func GetPrices(ctx context.Context, requests []*PriceRequest, + granularity Granularity) (map[string]float64, error) { + + if len(requests) == 0 { + return nil, nil + } + + log.Debugf("getting prices for: %v requests", len(requests)) + + // Make sure that every label that in the request set is unique. + uniqueLabels := make(map[string]bool, len(requests)) + for _, request := range requests { + _, ok := uniqueLabels[request.Identifier] + if ok { + return nil, errDuplicateLabel + } + + uniqueLabels[request.Identifier] = true + } + + // Get the minimum and maximum timestamps for our set of requests + // so that we can efficiently query for price data. + start, end := getQueryableDuration(requests) + + // Get a set of historical price data points. + coinCapBackend := newCoinCapAPI(granularity) + priceData, err := coinCapBackend.GetPrices(ctx, start, end) + if err != nil { + return nil, err + } + + // Prices will map transaction identifiers to their USD prices. + var prices = make(map[string]float64, len(requests)) + + for _, request := range requests { + price, err := getPrice(priceData, request) + if err != nil { + return nil, err + } + + prices[request.Identifier] = price + } + + return prices, nil +} + +// getQueryableDuration gets the smallest and largest timestamp from a set of +// requests so that we can query for an appropriate set of price data. +func getQueryableDuration(requests []*PriceRequest) (time.Time, time.Time) { + var start, end time.Time + // Iterate through our min and max times and get the time range over + // which we need to get price information. + for _, req := range requests { + if start.IsZero() || start.After(req.Timestamp) { + start = req.Timestamp + } + + if end.IsZero() || end.Before(req.Timestamp) { + end = req.Timestamp + } + } + + return start, end +} + +// msatToUSD converts a msat amount to usd. Note that this function coverts +// values to Bitcoin values, then gets the fiat price for that BTC value. If +// an amount < 1000 msat is given, a zero amount will be returned. +func msatToUSD(price float64, amt lnwire.MilliSatoshi) float64 { + btcBal := amt.ToBTC() + return price * btcBal +} + +// getPrice gets the price for a timestamped request from a set of price data. +// This function expects the price data to be sorted with ascending timestamps. +// If request lies between two price points, we simply aggregate the two prices. +func getPrice(prices []*usdPrice, request *PriceRequest) (float64, error) { + var lastPrice float64 + + if len(prices) == 0 { + return 0, errNoPrices + } + + for _, price := range prices { + // Check the optimistic case where the price timestamp matches + // our timestamp exactly. + if price.timestamp.Equal(request.Timestamp) { + return msatToUSD(price.price, request.Value), nil + } + + // Once we reach a price point that is before our request's + // timestamp, the request's timestamp lies somewhere between + // the current price data point and the previous on. + if request.Timestamp.Before(price.timestamp) { + // If the last price is 0, the request is after the + // very first price data point. We do not aggregate in + // this case. + if lastPrice == 0 { + return msatToUSD(price.price, request.Value), + nil + } + + // Otherwise, aggregate the price over the current data + // point and the next one. + price := (lastPrice + price.price) / 2 + return msatToUSD(price, request.Value), nil + } + + lastPrice = price.price + } + + // If we have fallen through to this point, the price's timestamp falls + // after our last price data point's timestamp. In this case, we just + // return the price quoted on that price. + return msatToUSD(lastPrice, request.Value), nil +} diff --git a/fiat/prices_test.go b/fiat/prices_test.go new file mode 100644 index 0000000..9a5bca2 --- /dev/null +++ b/fiat/prices_test.go @@ -0,0 +1,237 @@ +package fiat + +import ( + "testing" + "time" + + "github.com/lightningnetwork/lnd/lnwire" +) + +// TestGetPrice tests getting price from a set of price data. +func TestGetPrice(t *testing.T) { + now := time.Now() + oneHourAgo := now.Add(time.Hour * -1) + twoHoursAgo := now.Add(time.Hour * -2) + + tests := []struct { + name string + prices []*usdPrice + request *PriceRequest + expectedErr error + expectedPrice float64 + }{ + { + name: "no prices", + prices: nil, + request: &PriceRequest{ + Value: 1, + Timestamp: oneHourAgo, + }, + expectedErr: errNoPrices, + }, + { + name: "timestamp before range", + prices: []*usdPrice{ + { + timestamp: now, + price: 10000, + }, + }, + request: &PriceRequest{ + Value: 1, + Timestamp: oneHourAgo, + }, + expectedErr: nil, + expectedPrice: msatToUSD(10000, 1), + }, + { + name: "timestamp equals data point timestamp", + prices: []*usdPrice{ + { + timestamp: oneHourAgo, + price: 10000, + }, + { + timestamp: now, + price: 10000, + }, + }, + request: &PriceRequest{ + Value: 2, + Timestamp: now, + }, + expectedErr: nil, + expectedPrice: msatToUSD(10000, 2), + }, + { + name: "timestamp after range", + prices: []*usdPrice{ + { + timestamp: twoHoursAgo, + price: 20000, + }, + { + timestamp: oneHourAgo, + price: 10000, + }, + }, + request: &PriceRequest{ + Value: 3, + Timestamp: now, + }, + expectedErr: nil, + expectedPrice: msatToUSD(10000, 3), + }, + { + name: "timestamp between prices, aggregated", + prices: []*usdPrice{ + { + timestamp: twoHoursAgo, + price: 20000, + }, + { + timestamp: now, + price: 10000, + }, + }, + request: &PriceRequest{ + Value: 3, + Timestamp: oneHourAgo, + }, + expectedErr: nil, + expectedPrice: msatToUSD((20000+10000)/2, 3), + }, + } + + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + price, err := getPrice(test.prices, test.request) + if err != test.expectedErr { + t.Fatalf("expected: %v, got: %v", + test.expectedErr, err) + } + + if price != test.expectedPrice { + t.Fatalf("expected: %v, got: %v", + test.expectedPrice, price) + } + }) + } +} + +// TestGetQueryableDuration tests getting min/max from a set of timestamps. +func TestGetQueryableDuration(t *testing.T) { + now := time.Now() + yesterday := now.Add(time.Hour * -24) + + tests := []struct { + name string + requests []*PriceRequest + expectStart time.Time + expectEnd time.Time + }{ + { + name: "single ts", + requests: []*PriceRequest{ + { + Timestamp: now, + }, + }, + expectStart: now, + expectEnd: now, + }, + { + name: "different ts", + requests: []*PriceRequest{ + { + Timestamp: now, + }, + { + Timestamp: yesterday, + }, + }, + expectStart: yesterday, + expectEnd: now, + }, + { + name: "duplicate ts", + requests: []*PriceRequest{ + { + Timestamp: now, + }, + { + Timestamp: now, + }, + { + Timestamp: yesterday, + }, + }, + expectStart: yesterday, + expectEnd: now, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + start, end := getQueryableDuration(test.requests) + if !start.Equal(test.expectStart) { + t.Fatalf("expected: %v, got: %v", + test.expectStart, start) + } + + if !end.Equal(test.expectEnd) { + t.Fatalf("expected: %v, got: %v", + test.expectEnd, end) + } + }) + } +} + +// TestMSatToUsd tests conversion of msat to usd. This +func TestMSatToUsd(t *testing.T) { + tests := []struct { + name string + amount lnwire.MilliSatoshi + price float64 + expectedFiat float64 + }{ + { + name: "1 sat not rounded down", + amount: 1000, + price: 10000, + expectedFiat: 0.0001, + }, + { + name: "1 msat rounded down", + amount: 1, + price: 10000, + expectedFiat: 0, + }, + { + name: "1 btc + 1 msat rounded down", + amount: 100000000001, + price: 10000, + expectedFiat: 10000, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + amt := msatToUSD(test.price, test.amount) + if amt != test.expectedFiat { + t.Fatalf("expected: %v, got: %v", + test.expectedFiat, amt) + } + }) + } +}