Skip to content

Commit

Permalink
Merge pull request #4 from hrharder/feature/add-cached-closure
Browse files Browse the repository at this point in the history
gas: add cached gas price suggester closure with user-defined max age
  • Loading branch information
Henry Harder authored Jan 31, 2020
2 parents 608f647 + 9f8677e commit 8d9b0d9
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 42 deletions.
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
}
```
109 changes: 80 additions & 29 deletions gas_price.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
// 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 (
"encoding/json"
"errors"
"math/big"
"net/http"
"sync"
"time"
)

// ETHGasStationURL is the API URL for the ETH Gas Station API.
Expand All @@ -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")
Expand All @@ -29,44 +38,79 @@ 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) {
prices, err := loadGasPrices()
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) {
Expand All @@ -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")
}
Expand Down
45 changes: 32 additions & 13 deletions gas_price_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package gas

import (
"encoding/json"
"math/big"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -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)
Expand All @@ -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 := &ethGasStationResponse{
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")
}

0 comments on commit 8d9b0d9

Please sign in to comment.