synapsecns/sanguine

View on GitHub
ethergo/chain/block.go

Summary

Maintainability
B
4 hrs
Test Coverage
package chain

import (
    "context"
    "fmt"
    "math/big"
    "time"

    "github.com/ethereum/go-ethereum"
    "github.com/ethereum/go-ethereum/core/types"
    lru "github.com/hashicorp/golang-lru"
    "github.com/pkg/errors"
    "golang.org/x/sync/errgroup"
)

// HeaderByTime gets the closest block to a given time.
//
// It works by running a binary search against block headers from genesis until now.
// the user can optionally pass in a nullable startBlock *big.Int. If the block is left empty the search is performed from genesis
// the worst case time complexity makes this a cheap operation on any chain, but if done frequently a startBlock should be passed in as a heuristic
//
//nolint:gocognit,cyclop // we ignore complexity warnings here: breaking this up makes it much harder to read.
func (b *baseChain) HeaderByTime(ctx context.Context, startHeight *big.Int, targetTime time.Time) (*types.Header, error) {
    // grab the MeteredEVMClient explicitly to avoid ambiguous references
    client, err := newHeaderCacheClient(b, 4)
    if err != nil {
        return nil, fmt.Errorf("could not create header cache client: %w", err)
    }
    target := uint64(targetTime.Unix())

    // get the start block and end block
    searchRange, err := getSearchRange(ctx, client, startHeight)
    if err != nil {
        return nil, err
    }

    // handle corner cases
    if target <= searchRange.StartTime() {
        return searchRange.startBlock, nil
    }
    if target >= searchRange.EndTime() {
        return searchRange.endBlock, nil
    }

    // run the binary search
    var i, mid, j uint64
    i = searchRange.startBlock.Number.Uint64()
    j = searchRange.endBlock.Number.Uint64()

    for i < j {
        mid = (i + j) / 2

        midBlock, err := client.HeaderByNumber(ctx, big.NewInt(0).SetUint64(mid))
        if err != nil {
            return nil, errors.Wrapf(err, "could not get block %d", mid)
        }

        if midBlock.Time == target {
            return midBlock, nil
        }

        // If target is less than midBlock element
        // then search in left
        //nolint: nestif // we want to keep the logic for the binary search together
        if target < midBlock.Time {
            // If target is greater than previous to mid, return closest of two
            midSubBlock, err := client.HeaderByNumber(ctx, big.NewInt(0).SetUint64(mid-1))
            if err != nil {
                return nil, errors.Wrapf(err, "could not get block %d", mid-1)
            }

            if mid > 0 && target >= midSubBlock.Time {
                return getClosest(midSubBlock, midBlock, target), nil
            }

            j = mid
        } else {
            // If target is greater than mid
            midPlusBlock, err := client.HeaderByNumber(ctx, big.NewInt(0).SetUint64(mid+1))
            if err != nil {
                return nil, errors.Wrapf(err, "could not get block %d", mid+1)
            }

            if mid <= searchRange.Length()-1 && target <= midPlusBlock.Time {
                return getClosest(midBlock, midPlusBlock, target), nil
            }
            i = mid + 1
        }
    }

    // only a single element is left after the search. Fetch the block (from cache) and return it.
    resultingBlock, err := client.HeaderByNumber(ctx, big.NewInt(int64(mid)))
    if err != nil {
        return nil, fmt.Errorf("could not get resultng block")
    }

    return resultingBlock, nil
}

// getClosest gets the closer value to the target of the two blocks
//
// We find the closest by taking the difference between the target and both values
// this method assumes that blockB is greater than blockA and target lies between the two.
func getClosest(lesser *types.Header, greater *types.Header, target uint64) *types.Header {
    if (target - lesser.Time) >= (greater.Time - target) {
        return greater
    }
    return lesser
}

// getSearchRange gets the block range used for performing a binary search.
// an error is returned if either startBlock or endBlock is null. Uses 1 if startBlock is nil.
func getSearchRange(ctx context.Context, client ethereum.ChainReader, startHeight *big.Int) (_ *blockRange, err error) {
    var output blockRange
    if startHeight == nil || startHeight.Cmp(big.NewInt(0)) == 0 {
        startHeight = big.NewInt(1)
    }

    g, ctx := errgroup.WithContext(ctx)
    g.Go(func() error {
        output.startBlock, err = client.HeaderByNumber(ctx, startHeight)
        if err != nil {
            return fmt.Errorf("could not get start block: %w", err)
        }
        return nil
    })

    g.Go(func() error {
        output.endBlock, err = client.HeaderByNumber(ctx, nil)
        if err != nil {
            return fmt.Errorf("could not get start block: %w", err)
        }
        return nil
    })

    err = g.Wait()
    if err != nil {
        return nil, fmt.Errorf("unable to get search range: %w", err)
    }
    return &output, nil
}

// blockRange contains data about a range of block
// accessor methods are exposed for performing a binary search.
type blockRange struct {
    // startBlock of the range. This should not be nil
    startBlock *types.Header

    // endBlock of the range. Will always be greater than start block
    endBlock *types.Header
}

// Length gets the length of the block range as a uint64.
func (b *blockRange) Length() uint64 {
    return big.NewInt(0).Sub(b.endBlock.Number, b.startBlock.Number).Uint64() + 1
}

// StartTime gets the start time of the range as a unix timestamp.
func (b *blockRange) StartTime() uint64 {
    return b.startBlock.Time
}

// EndTime gets the end time of the range as a unix timestamp.
func (b *blockRange) EndTime() uint64 {
    return b.endBlock.Time
}

// headerCacheClient is a client that caches the last count headers
// we use this here so we don't have to store midBlock/handle instances of cached vs uncached blocks differently.
type headerCacheClient struct {
    ethereum.ChainReader
    // headerCache holds the headers
    headerCache *lru.Cache
}

// newHeaderCacheClient creates a client that contains a header cache
// the cache checks if the header exists and if not fetches it. If the cache size
// exceeds maxSize the oldest item is removed.
//
// this cache is intended for use with HeaderByTime to prevent refetching blocks when iterating
// but can be used generally. This will also not handle reorgs since doing so would require a refetch.
func newHeaderCacheClient(ethClient ethereum.ChainReader, count int) (*headerCacheClient, error) {
    cache, err := lru.New(count)
    if err != nil {
        return nil, fmt.Errorf("could not create header cache: %w", err)
    }
    return &headerCacheClient{
        ChainReader: ethClient,
        headerCache: cache,
    }, nil
}

// HeaderByNumber wraps header cache client and introduces caching.
func (c *headerCacheClient) HeaderByNumber(ctx context.Context, number *big.Int) (header *types.Header, err error) {
    // handle latest block query. We cache this by number after the fetch
    if number == nil {
        return c.fetchAndStoreHeader(ctx, number)
    }

    rawHeader, hasKey := c.headerCache.Get(number.Uint64())
    if hasKey {
        var ok bool
        header, ok = rawHeader.(*types.Header)
        // we should always be able to cast here, but if we can't fetch and store the header
        if !ok {
            logger.Warnf("couldn't cast header of type %T to %T. cache may be corrupted", rawHeader, &types.Header{})
            return c.fetchAndStoreHeader(ctx, number)
        }
        return header, nil
    }

    return c.fetchAndStoreHeader(ctx, number)
}

// fetchAndStoreHeader fetches and stores the header bypassing the cache.
func (c *headerCacheClient) fetchAndStoreHeader(ctx context.Context, number *big.Int) (header *types.Header, err error) {
    fetchedBlock, err := c.ChainReader.HeaderByNumber(ctx, number)
    if err != nil {
        return nil, fmt.Errorf("could not fetch latest block: %w", err)
    }
    // add the header to the cache
    c.headerCache.Add(fetchedBlock.Number.Uint64(), fetchedBlock)
    return fetchedBlock, nil
}