Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

eth,eth/watcher: Create Chainlink price feed watcher #2972

Merged
merged 10 commits into from
Mar 27, 2024
18 changes: 15 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
SHELL=/bin/bash
GO_BUILD_DIR?="./"

all: net/lp_rpc.pb.go net/redeemer.pb.go net/redeemer_mock.pb.go core/test_segment.go livepeer livepeer_cli livepeer_router livepeer_bench
MOCKGEN=go run github.com/golang/mock/mockgen
ABIGEN=go run github.com/ethereum/go-ethereum/cmd/abigen

all: net/lp_rpc.pb.go net/redeemer.pb.go net/redeemer_mock.pb.go core/test_segment.go eth/contracts/chainlink/AggregatorV3Interface.go livepeer livepeer_cli livepeer_router livepeer_bench

net/lp_rpc.pb.go: net/lp_rpc.proto
protoc -I=. --go_out=. --go-grpc_out=. $^
Expand All @@ -10,12 +13,21 @@ net/redeemer.pb.go: net/redeemer.proto
protoc -I=. --go_out=. --go-grpc_out=. $^

net/redeemer_mock.pb.go net/redeemer_grpc_mock.pb.go: net/redeemer.pb.go net/redeemer_grpc.pb.go
@mockgen -source net/redeemer.pb.go -destination net/redeemer_mock.pb.go -package net
@mockgen -source net/redeemer_grpc.pb.go -destination net/redeemer_grpc_mock.pb.go -package net
@$(MOCKGEN) -source net/redeemer.pb.go -destination net/redeemer_mock.pb.go -package net
@$(MOCKGEN) -source net/redeemer_grpc.pb.go -destination net/redeemer_grpc_mock.pb.go -package net

core/test_segment.go:
core/test_segment.sh core/test_segment.go

eth/contracts/chainlink/AggregatorV3Interface.go:
solc --version | grep 0.7.6+commit.7338295f
@set -ex; \
for sol_file in eth/contracts/chainlink/*.sol; do \
contract_name=$$(basename "$$sol_file" .sol); \
solc --abi --optimize --overwrite -o $$(dirname "$$sol_file") $$sol_file; \
$(ABIGEN) --abi=$${sol_file%.sol}.abi --pkg=chainlink --type=$$contract_name --out=$${sol_file%.sol}.go; \
done

version=$(shell cat VERSION)

ldflags := -X github.com/livepeer/go-livepeer/core.LivepeerVersion=$(shell ./print_version.sh)
Expand Down
1 change: 1 addition & 0 deletions eth/contracts/chainlink/AggregatorV3Interface.abi
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"description","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint80","name":"_roundId","type":"uint80"}],"name":"getRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"version","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]
394 changes: 394 additions & 0 deletions eth/contracts/chainlink/AggregatorV3Interface.go

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions eth/contracts/chainlink/AggregatorV3Interface.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
// https://github.com/smartcontractkit/chainlink/blob/v2.9.1/contracts/src/v0.7/interfaces/AggregatorV3Interface.sol
pragma solidity ^0.7.0;

interface AggregatorV3Interface {
function decimals() external view returns (uint8);

function description() external view returns (string memory);

function version() external view returns (uint256);

// getRoundData and latestRoundData should both raise "No data present"
// if they do not have data to report, instead of returning unset values
// which could be misinterpreted as actual reported values.
function getRoundData(uint80 _roundId)
external
view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);

function latestRoundData()
external
view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
}
117 changes: 117 additions & 0 deletions eth/pricefeed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package eth

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

"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/livepeer/go-livepeer/eth/contracts/chainlink"
)

type PriceData struct {
RoundID int64
Price *big.Rat
UpdatedAt time.Time
}

// PriceFeedEthClient is an interface for fetching price data from a Chainlink
// PriceFeed contract.
type PriceFeedEthClient interface {
Description() (string, error)
FetchPriceData() (PriceData, error)
}

func NewPriceFeedEthClient(ctx context.Context, rpcUrl, priceFeedAddr string) (PriceFeedEthClient, error) {
client, err := ethclient.DialContext(ctx, rpcUrl)
if err != nil {
return nil, fmt.Errorf("failed to initialize client: %w", err)

Check warning on line 33 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L30-L33

Added lines #L30 - L33 were not covered by tests
}

ok := isContractAddress(priceFeedAddr, client)
if !ok {
victorges marked this conversation as resolved.
Show resolved Hide resolved
return nil, fmt.Errorf("not a contract address: %s", priceFeedAddr)

Check warning on line 38 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L36-L38

Added lines #L36 - L38 were not covered by tests
}

addr := common.HexToAddress(priceFeedAddr)
priceFeed, err := chainlink.NewAggregatorV3Interface(addr, client)
if err != nil {
return nil, fmt.Errorf("failed to create mock aggregator proxy: %w", err)

Check warning on line 44 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L41-L44

Added lines #L41 - L44 were not covered by tests
victorges marked this conversation as resolved.
Show resolved Hide resolved
}

return &priceFeedClient{
client: client,
priceFeed: priceFeed,
}, nil

Check warning on line 50 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L47-L50

Added lines #L47 - L50 were not covered by tests
}

type priceFeedClient struct {
client *ethclient.Client
priceFeed *chainlink.AggregatorV3Interface
}

func (c *priceFeedClient) Description() (string, error) {
return c.priceFeed.Description(&bind.CallOpts{})

Check warning on line 59 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L58-L59

Added lines #L58 - L59 were not covered by tests
}

func (c *priceFeedClient) FetchPriceData() (PriceData, error) {
data, err := c.priceFeed.LatestRoundData(&bind.CallOpts{})
if err != nil {
return PriceData{}, errors.New("failed to get latest round data: " + err.Error())

Check warning on line 65 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L62-L65

Added lines #L62 - L65 were not covered by tests
}

decimals, err := c.priceFeed.Decimals(&bind.CallOpts{})
if err != nil {
return PriceData{}, errors.New("failed to get decimals: " + err.Error())

Check warning on line 70 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L68-L70

Added lines #L68 - L70 were not covered by tests
}

return computePriceData(data.RoundId, data.UpdatedAt, data.Answer, decimals), nil

Check warning on line 73 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L73

Added line #L73 was not covered by tests
}

// computePriceData transforms the raw data from the PriceFeed into the higher
// level PriceData struct, more easily usable by the rest of the system.
func computePriceData(roundID, updatedAt, answer *big.Int, decimals uint8) PriceData {
// Compute a big.int which is 10^decimals.
divisor := new(big.Int).Exp(
big.NewInt(10),
big.NewInt(int64(decimals)),
nil)

return PriceData{
RoundID: roundID.Int64(),
Price: new(big.Rat).SetFrac(answer, divisor),
UpdatedAt: time.Unix(updatedAt.Int64(), 0),
}
}

type ethClient interface {
CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error)
}

// isContractAddress checks if the given address is an address of a contract
// deployed on the corresponding blockchain.
func isContractAddress(addr string, client ethClient) bool {
victorges marked this conversation as resolved.
Show resolved Hide resolved
if len(addr) == 0 {
return false
}

// Ensure it is an Ethereum address: 0x followed by 40 hexadecimal characters.
re := regexp.MustCompile("^0x[0-9a-fA-F]{40}$")
if !re.MatchString(addr) {
return false
}

// Ensure it is a contract address.
address := common.HexToAddress(addr)
bytecode, err := client.CodeAt(context.Background(), address, nil) // nil is latest block
if err != nil {
return false

Check warning on line 113 in eth/pricefeed.go

View check run for this annotation

Codecov / codecov/patch

eth/pricefeed.go#L113

Added line #L113 was not covered by tests
}
isContract := len(bytecode) > 0
return isContract
}
93 changes: 93 additions & 0 deletions eth/pricefeed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package eth

import (
"context"
"math/big"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type mockEthClient struct {
mock.Mock
}

func (m *mockEthClient) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) {
args := m.Called(ctx, contract, blockNumber)
return args.Get(0).([]byte), args.Error(1)
}

func TestIsContractAddress(t *testing.T) {
assert := assert.New(t)

t.Run("invalid address", func(t *testing.T) {
addr := "0x123"
mockClient := new(mockEthClient)
assert.False(isContractAddress(addr, mockClient))
})

t.Run("empty address", func(t *testing.T) {
addr := ""
mockClient := new(mockEthClient)
assert.False(isContractAddress(addr, mockClient))
})

t.Run("valid address but not contract", func(t *testing.T) {
addr := "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B"
mockClient := new(mockEthClient)
mockClient.On("CodeAt", mock.Anything, common.HexToAddress(addr), mock.Anything).Return([]byte{}, nil)
assert.False(isContractAddress(addr, mockClient))
})

t.Run("valid contract address", func(t *testing.T) {
addr := "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B"
mockClient := new(mockEthClient)
mockClient.On("CodeAt", mock.Anything, common.HexToAddress(addr), mock.Anything).Return([]byte{0x1}, nil)
assert.True(isContractAddress(addr, mockClient))
})
}

func TestComputePriceData(t *testing.T) {
assert := assert.New(t)

t.Run("valid data", func(t *testing.T) {
roundID := big.NewInt(1)
updatedAt := big.NewInt(1626192000)
answer := big.NewInt(420666000)
decimals := uint8(6)

data := computePriceData(roundID, updatedAt, answer, decimals)

assert.EqualValues(int64(1), data.RoundID, "Round ID didn't match")
assert.Equal("210333/500", data.Price.RatString(), "The Price Rat didn't match")
assert.Equal("2021-07-13 16:00:00 +0000 UTC", data.UpdatedAt.UTC().String(), "The updated at time did not match")
})

t.Run("zero answer", func(t *testing.T) {
roundID := big.NewInt(2)
updatedAt := big.NewInt(1626192000)
answer := big.NewInt(0)
decimals := uint8(18)

data := computePriceData(roundID, updatedAt, answer, decimals)

assert.EqualValues(int64(2), data.RoundID, "Round ID didn't match")
assert.Equal("0", data.Price.RatString(), "The Price Rat didn't match")
assert.Equal("2021-07-13 16:00:00 +0000 UTC", data.UpdatedAt.UTC().String(), "The updated at time did not match")
})

t.Run("zero decimals", func(t *testing.T) {
roundID := big.NewInt(3)
updatedAt := big.NewInt(1626192000)
answer := big.NewInt(13)
decimals := uint8(0)

data := computePriceData(roundID, updatedAt, answer, decimals)

assert.EqualValues(int64(3), data.RoundID, "Round ID didn't match")
assert.Equal("13", data.Price.RatString(), "The Price Rat didn't match")
assert.Equal("2021-07-13 16:00:00 +0000 UTC", data.UpdatedAt.UTC().String(), "The updated at time did not match")
})
}
Loading
Loading