diff --git a/README.md b/README.md index 23e1b92..1dcea73 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,25 @@ go get -u github.com/hrharder/go-gas ## Usage +Package `gas` provides two main ways to fetch a gas price from the ETH Gas Station API. + +1. Fetch the current recommended price for a given priority level with a new API call each time + - Use `gas.SuggestGasPrice` for a specific priority level + - Use `gas.SuggestFastGasPrice` to fetch the fast priority level (no arguments) +1. Create a new `GasPriceSuggester` which maintains a cache of results for a user-defined duration + - Use `gas.NewGasPriceSuggester` and specify a max result age + - Use the returned function to fetch new gas prices, or use the cache based on how old the results are + + +### Example + ```go package main import ( "fmt" "log" + "time" "github.com/hrharder/go-gas" ) @@ -43,5 +56,26 @@ func main() { fmt.Println(fastestGasPrice) fmt.Println(fastGasPrice) + + // alternatively, use the NewGasPriceSuggester which maintains a cache of results until they are older than max age + suggestGasPrice, err := gas.NewGasPriceSuggester(5 * time.Minute) + if err != nil { + log.Fatal(err) + } + + fastGasPriceFromCache, err := suggestGasPrice(gas.GasPriorityFast) + if err != nil { + return nil, err + } + + // after 5 minutes, the cache will be invalidated and new results will be fetched + time.Sleep(5 * time.Minute) + fasGasPriceFromAPI, err := suggestGasPrice(gas.GasPriorityFast) + if err != nil { + log.Fatal(err) + } + + fmt.Println(fastGasPriceFromCache) + fmt.Println(fasGasPriceFromAPI) } ``` diff --git a/gas_price.go b/gas_price.go index b0697ce..4dae0f3 100644 --- a/gas_price.go +++ b/gas_price.go @@ -1,3 +1,7 @@ +// Package gas provides a client for the ETH Gas Station API and convenience functions. +// +// It includes type aliases for each priority level supported by ETH Gas Station, functions to get the lastest price +// from the API, and a closure that can be used to cache results for a user-defined period of time. package gas import ( @@ -5,6 +9,8 @@ import ( "errors" "math/big" "net/http" + "sync" + "time" ) // ETHGasStationURL is the API URL for the ETH Gas Station API. @@ -15,6 +21,9 @@ const ETHGasStationURL = "https://ethgasstation.info/json/ethgasAPI.json" // GasPriority is a type alias for a string, with supported priorities included in this package. type GasPriority string +// GasPriceSuggester is type alias for a function that returns a reccomended gas price in base units for a given priority level. +type GasPriceSuggester func(GasPriority) (*big.Int, error) + const ( // GasPriorityFast is the recommended gas price for a transaction to be mined in less than 2 minutes. GasPriorityFast = GasPriority("fast") @@ -29,7 +38,8 @@ const ( GasPriorityAverage = GasPriority("average") ) -// SuggestGasPrice returns a suggested gas price value in wei (base units) for timely transaction execution. +// SuggestGasPrice returns a suggested gas price value in wei (base units) for timely transaction execution. It always +// makes a new call to the ETH Gas Station API. Use NewGasPriceSuggester to leverage cached results. // // The returned price depends on the priority specified, and supports all priorities supported by the ETH Gas Station API. func SuggestGasPrice(priority GasPriority) (*big.Int, error) { @@ -37,36 +47,70 @@ func SuggestGasPrice(priority GasPriority) (*big.Int, error) { if err != nil { return nil, err } - - switch priority { - case GasPriorityFast: - return parseGasPriceToWei(prices.Fast) - case GasPriorityFastest: - return parseGasPriceToWei(prices.Fastest) - case GasPrioritySafeLow: - return parseGasPriceToWei(prices.SafeLow) - case GasPriorityAverage: - return parseGasPriceToWei(prices.Average) - default: - return nil, errors.New("eth: unknown/unsupported gas priority") - } + return parseSuggestedGasPrice(priority, prices) } // SuggestFastGasPrice is a helper method that calls SuggestGasPrice with GasPriorityFast +// +// It always makes a new call to the ETH Gas Station API. Use NewGasPriceSuggester to leverage cached results. func SuggestFastGasPrice() (*big.Int, error) { return SuggestGasPrice(GasPriorityFast) } +// NewGasPriceSuggester returns a function that can be used to either load a new gas price response, or use a cached +// response if it is within the age range defined by maxResultAge. +// +// The returned function loads from the cache or pulls a new response if the stored result is older than maxResultAge. +func NewGasPriceSuggester(maxResultAge time.Duration) (GasPriceSuggester, error) { + prices, err := loadGasPrices() + if err != nil { + return nil, err + } + m := gasPriceManager{ + latestResponse: prices, + fetchedAt: time.Now(), + maxResultAge: maxResultAge, + } + return func(priority GasPriority) (*big.Int, error) { + return m.suggestCachedGasPrice(priority) + }, nil +} + +type gasPriceManager struct { + sync.Mutex + + latestResponse *ethGasStationResponse + fetchedAt time.Time + maxResultAge time.Duration +} + +func (m *gasPriceManager) suggestCachedGasPrice(priority GasPriority) (*big.Int, error) { + m.Lock() + defer m.Unlock() + + // fetch new values if stored result is older than the maximum age + if time.Since(m.fetchedAt) > m.maxResultAge { + prices, err := loadGasPrices() + if err != nil { + return nil, err + } + m.latestResponse = prices + m.fetchedAt = time.Now() + } + + return parseSuggestedGasPrice(priority, m.latestResponse) +} + // conversion factor to go from (gwei * 10) to wei // equal to: (raw / 10) => gwei => gwei * 1e9 => wei // simplifies to: raw * 1e8 => wei var conversionFactor = big.NewFloat(100000000) type ethGasStationResponse struct { - Fast json.Number `json:"fast"` - Fastest json.Number `json:"fastest"` - SafeLow json.Number `json:"safeLow"` - Average json.Number `json:"average"` + Fast float64 `json:"fast"` + Fastest float64 `json:"fastest"` + SafeLow float64 `json:"safeLow"` + Average float64 `json:"average"` } func loadGasPrices() (*ethGasStationResponse, error) { @@ -75,26 +119,33 @@ func loadGasPrices() (*ethGasStationResponse, error) { return nil, err } - dcr := json.NewDecoder(res.Body) - dcr.UseNumber() - var body ethGasStationResponse - if err := dcr.Decode(&body); err != nil { + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { return nil, err } return &body, nil } -// convert eth gas station units to wei -// (raw result / 10) * 1e9 = base units (wei) -func parseGasPriceToWei(raw json.Number) (*big.Int, error) { - num, ok := new(big.Float).SetString(raw.String()) - if !ok { - return nil, errors.New("eth: unable to parse float value") +func parseSuggestedGasPrice(priority GasPriority, prices *ethGasStationResponse) (*big.Int, error) { + switch priority { + case GasPriorityFast: + return parseGasPriceToWei(prices.Fast) + case GasPriorityFastest: + return parseGasPriceToWei(prices.Fastest) + case GasPrioritySafeLow: + return parseGasPriceToWei(prices.SafeLow) + case GasPriorityAverage: + return parseGasPriceToWei(prices.Average) + default: + return nil, errors.New("eth: unknown/unsupported gas priority") } +} - gwei := new(big.Float).Mul(num, conversionFactor) +// convert eth gas station units to wei +// (raw result / 10) * 1e9 = base units (wei) +func parseGasPriceToWei(raw float64) (*big.Int, error) { + gwei := new(big.Float).Mul(big.NewFloat(raw), conversionFactor) if !gwei.IsInt() { return nil, errors.New("eth: unable to represent gas price as integer") } diff --git a/gas_price_test.go b/gas_price_test.go index 1769936..e8e2ee6 100644 --- a/gas_price_test.go +++ b/gas_price_test.go @@ -1,9 +1,9 @@ package gas import ( - "encoding/json" "math/big" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,7 +23,7 @@ func TestSuggestGasPrice(t *testing.T) { } func TestParseGasPriceToWei(t *testing.T) { - oneGweiInGasStationUnits := json.Number("10") + oneGweiInGasStationUnits := 10.0 oneGweiInBaseUnits := big.NewInt(int64(1e9)) parsed, err := parseGasPriceToWei(oneGweiInGasStationUnits) @@ -36,17 +36,36 @@ func TestLoadGasPrices(t *testing.T) { rawPrices, err := loadGasPrices() require.NoError(t, err) - fast, err := rawPrices.Fast.Float64() - require.NoError(t, err) - fastest, err := rawPrices.Fastest.Float64() - require.NoError(t, err) - safeLow, err := rawPrices.SafeLow.Float64() - require.NoError(t, err) - average, err := rawPrices.Average.Float64() + require.GreaterOrEqual(t, rawPrices.Fastest, rawPrices.Fast) + require.GreaterOrEqual(t, rawPrices.Fast, rawPrices.Average) + require.GreaterOrEqual(t, rawPrices.Average, rawPrices.SafeLow) + require.GreaterOrEqual(t, rawPrices.SafeLow, 0.0) +} + +func TestGasPriceManager(t *testing.T) { + // create "phony" negative price result so we know the cache is being used + prices := ðGasStationResponse{ + Fast: -1.0, + Fastest: -1.0, + SafeLow: -1.0, + Average: -1.0, + } + + mgr := gasPriceManager{ + latestResponse: prices, + fetchedAt: time.Now(), + maxResultAge: 50 * time.Millisecond, + } + + // 1. should use a cached result up til duration has passed + // - we can ensure a cached result is used by manually setting a cached result as -1 + cachedResult, err := mgr.suggestCachedGasPrice(GasPriorityFast) require.NoError(t, err) + assert.Equal(t, "-100000000", cachedResult.String(), "cached result should be negative since we manually set the result") - assert.GreaterOrEqual(t, fastest, fast) - assert.GreaterOrEqual(t, fast, average) - assert.GreaterOrEqual(t, average, safeLow) - assert.GreaterOrEqual(t, safeLow, 0.0) + // 2. should fetch a new result after duration has passed + time.Sleep(51 * time.Millisecond) + newResult, err := mgr.suggestCachedGasPrice(GasPriorityFast) + require.NoError(t, err) + assert.Equal(t, newResult.Cmp(big.NewInt(0)), 1, "new result should be greater than 0") }