diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..71b970a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: nanmu42 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..fc72752 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: "42 6 * * 0" + +jobs: + build: + environment: "CI Test" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: ">=1.17.4" + + - name: Test + env: + ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + run: go test -v -coverprofile=coverage.txt -covermode=count ./... + + - name: Codecov + uses: codecov/codecov-action@v2.1.0 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index e0ab53c..c7dafde 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +coverage.txt \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 297a5cc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: go - -env: - - GO111MODULE=on - -script: - - go test -v -coverprofile=coverage.txt -covermode=count ./... - -after_success: - - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/README.md b/README.md index 69491d5..56acd1f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,21 @@ +**English** | [中文](https://github.com/nanmu42/etherscan-api/blob/master/README_ZH.md) + # etherscan-api -[![Build Status](https://travis-ci.org/nanmu42/etherscan-api.svg?branch=master)](https://travis-ci.org/nanmu42/etherscan-api) -[![Go Report Card](https://goreportcard.com/badge/github.com/nanmu42/etherscan-api)](https://goreportcard.com/report/github.com/nanmu42/etherscan-api) -[![codecov](https://codecov.io/gh/nanmu42/etherscan-api/branch/master/graph/badge.svg)](https://codecov.io/gh/nanmu42/etherscan-api) [![GoDoc](https://godoc.org/github.com/nanmu42/etherscan-api?status.svg)](https://godoc.org/github.com/nanmu42/etherscan-api) -[中文文档](https://github.com/nanmu42/etherscan-api/blob/master/README_ZH.md) +[![CI status](https://github.com/nanmu42/etherscan-api/actions/workflows/ci.yaml/badge.svg)](https://github.com/nanmu42/etherscan-api/actions) +[![codecov](https://codecov.io/gh/nanmu42/etherscan-api/branch/master/graph/badge.svg)](https://codecov.io/gh/nanmu42/etherscan-api) +[![Go Report Card](https://goreportcard.com/badge/github.com/nanmu42/etherscan-api)](https://goreportcard.com/report/github.com/nanmu42/etherscan-api) -Go bindings to the Etherscan.io API(and its families like BscScan), with nearly Full implementation(accounts, transactions, tokens, contracts, blocks, stats), full network support(Mainnet, Ropsten, Kovan, Rinkby, Tobalaba), and only depending on standard library. :wink: +Golang client for the Etherscan.io API(and its families like BscScan), with nearly full implementation(accounts, transactions, tokens, contracts, blocks, stats), full network support(Mainnet, Ropsten, Kovan, Rinkby, Goerli, Tobalaba), and only depending on standard library. :wink: # Usage -Create a API instance and off you go. :rocket: +```bash +go get github.com/nanmu42/etherscan-api +``` + +Create an API instance and off you go. :rocket: ```go import ( @@ -70,6 +75,6 @@ I am not from Etherscan and I just find their service really useful, so I implem # License -Use of this work is governed by a MIT License. +Use of this work is governed by an MIT License. You may find a license copy in project root. diff --git a/README_ZH.md b/README_ZH.md index aeb71a1..905a770 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,17 +1,22 @@ +[English](https://github.com/nanmu42/etherscan-api/blob/master/README.md) | **中文** + # etherscan-api -[![Build Status](https://travis-ci.org/nanmu42/etherscan-api.svg?branch=master)](https://travis-ci.org/nanmu42/etherscan-api) -[![Go Report Card](https://goreportcard.com/badge/github.com/nanmu42/etherscan-api)](https://goreportcard.com/report/github.com/nanmu42/etherscan-api) -[![codecov](https://codecov.io/gh/nanmu42/etherscan-api/branch/master/graph/badge.svg)](https://codecov.io/gh/nanmu42/etherscan-api) [![GoDoc](https://godoc.org/github.com/nanmu42/etherscan-api?status.svg)](https://godoc.org/github.com/nanmu42/etherscan-api) -[English Readme](https://github.com/nanmu42/etherscan-api/blob/master/README.md) +[![CI status](https://github.com/nanmu42/etherscan-api/actions/workflows/ci.yaml/badge.svg)](https://github.com/nanmu42/etherscan-api/actions) +[![codecov](https://codecov.io/gh/nanmu42/etherscan-api/branch/master/graph/badge.svg)](https://codecov.io/gh/nanmu42/etherscan-api) +[![Go Report Card](https://goreportcard.com/badge/github.com/nanmu42/etherscan-api)](https://goreportcard.com/report/github.com/nanmu42/etherscan-api) -Etherscan.io的Golang实现, +Etherscan API的Golang客户端, 支持几乎所有功能(accounts, transactions, tokens, contracts, blocks, stats), -所有公共网络(Mainnet, Ropsten, Kovan, Rinkby, Tobalaba)。 +所有公共网络(Mainnet, Ropsten, Kovan, Rinkby, Goerli, Tobalaba)。 本项目只依赖于官方库。 :wink: -# Usage +# 使用方法 + +```bash +go get github.com/nanmu42/etherscan-api +``` 填入网络选项和API Key即可开始使用。 :rocket: diff --git a/account.go b/account.go index 48c149b..e6a9ac1 100644 --- a/account.go +++ b/account.go @@ -107,6 +107,54 @@ func (c *Client) ERC20Transfers(contractAddress, address *string, startBlock *in return } +// ERC721Transfers get a list of "erc721 - token transfer events" by +// contract address and/or from/to address. +// +// leave undesired condition to nil. +func (c *Client) ERC721Transfers(contractAddress, address *string, startBlock *int, endBlock *int, page int, offset int, desc bool) (txs []ERC721Transfer, err error) { + param := M{ + "page": page, + "offset": offset, + } + compose(param, "contractaddress", contractAddress) + compose(param, "address", address) + compose(param, "startblock", startBlock) + compose(param, "endblock", endBlock) + + if desc { + param["sort"] = "desc" + } else { + param["sort"] = "asc" + } + + err = c.call("account", "tokennfttx", param, &txs) + return +} + +// ERC1155Transfers get a list of "erc1155 - token transfer events" by +// contract address and/or from/to address. +// +// leave undesired condition to nil. +func (c *Client) ERC1155Transfers(contractAddress, address *string, startBlock *int, endBlock *int, page int, offset int, desc bool) (txs []ERC1155Transfer, err error) { + param := M{ + "page": page, + "offset": offset, + } + compose(param, "contractaddress", contractAddress) + compose(param, "address", address) + compose(param, "startblock", startBlock) + compose(param, "endblock", endBlock) + + if desc { + param["sort"] = "desc" + } else { + param["sort"] = "asc" + } + + err = c.call("account", "token1155tx", param, &txs) + return +} + // BlocksMinedByAddress gets list of blocks mined by address func (c *Client) BlocksMinedByAddress(address string, page int, offset int) (mined []MinedBlock, err error) { param := M{ diff --git a/account_e2e_test.go b/account_e2e_test.go index 48632b1..f7688e3 100644 --- a/account_e2e_test.go +++ b/account_e2e_test.go @@ -8,6 +8,8 @@ package etherscan import ( + "encoding/json" + "fmt" "math/big" "testing" ) @@ -149,3 +151,39 @@ func TestClient_TokenBalance(t *testing.T) { t.Errorf("api.TokenBalance not working, got balance %s", balance.Int().String()) } } + +func TestClient_ERC721Transfers(t *testing.T) { + const ( + wantLen = 351 + ) + + var a, b = 4708442, 9231168 + var contract, address = "0x06012c8cf97bead5deae237070f9587f8e7a266d", "0x6975be450864c02b4613023c2152ee0743572325" + txs, err := api.ERC721Transfers(&contract, &address, &a, &b, 1, 500, true) + noError(t, err, "api.ERC721Transfers") + + j, _ := json.MarshalIndent(txs, "", " ") + fmt.Printf("%s\n", j) + + if len(txs) != wantLen { + t.Errorf("got txs length %v, want %v", len(txs), wantLen) + } +} + +func TestClient_ERC1155Transfers(t *testing.T) { + const ( + wantLen = 1 + ) + + var a, b = 128135633, 1802672 + var contract, address = "0x3edf71a31b80Ff6a45Fdb0858eC54DE98dF047AA", "0x4b986EF20Bb83532911521FB4F6F5605122a0721" + txs, err := api.ERC1155Transfers(&contract, &address, &b, &a, 0, 0, true) + noError(t, err, "api.ERC721Transfers") + + j, _ := json.MarshalIndent(txs, "", " ") + fmt.Printf("%s\n", j) + + if len(txs) != wantLen { + t.Errorf("got txs length %v, want %v", len(txs), wantLen) + } +} diff --git a/block.go b/block.go index 8c3736b..aea4ea1 100644 --- a/block.go +++ b/block.go @@ -7,6 +7,11 @@ package etherscan +import ( + "fmt" + "strconv" +) + // BlockReward gets block and uncle rewards by block number func (c *Client) BlockReward(blockNum int) (rewards BlockRewards, err error) { param := M{ @@ -16,3 +21,29 @@ func (c *Client) BlockReward(blockNum int) (rewards BlockRewards, err error) { err = c.call("block", "getblockreward", param, &rewards) return } + +// BlockNumber gets the closest block number by UNIX timestamp +// +// valid closest option: before, after +func (c *Client) BlockNumber(timestamp int64, closest string) (blockNumber int, err error) { + var blockNumberStr string + + param := M{ + "timestamp": strconv.FormatInt(timestamp, 10), + "closest": closest, + } + + err = c.call("block", "getblocknobytime", param, &blockNumberStr) + + if err != nil { + return + } + + blockNumber, err = strconv.Atoi(blockNumberStr) + if err != nil { + err = fmt.Errorf("parsing block number %q: %w", blockNumberStr, err) + return + } + + return +} diff --git a/block_e2e_test.go b/block_e2e_test.go index 26b603e..c49a8ac 100644 --- a/block_e2e_test.go +++ b/block_e2e_test.go @@ -24,3 +24,23 @@ func TestClient_BlockReward(t *testing.T) { t.Errorf("api.BlockReward not working, got %s, want %s", j, ans) } } + +func TestClient_BlockNumber(t *testing.T) { + // Note: All values taken from docs.etherscan.io/api-endpoints/blocks + const ansBefore = 9251482 + const ansAfter = 9251483 + + blockNumber, err := api.BlockNumber(1578638524, "before") + noError(t, err, "api.BlockNumber") + + if blockNumber != ansBefore { + t.Errorf(`api.BlockNumber(1578638524, "before") not working, got %d, want %d`, blockNumber, ansBefore) + } + + blockNumber, err = api.BlockNumber(1578638524, "after") + noError(t, err, "api.BlockNumber") + + if blockNumber != ansAfter { + t.Errorf(`api.BlockNumber(1578638524,"after") not working, got %d, want %d`, blockNumber, ansAfter) + } +} diff --git a/gas_tracker.go b/gas_tracker.go new file mode 100644 index 0000000..02fe5ca --- /dev/null +++ b/gas_tracker.go @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Avi Misra + * + * Use of this work is governed by a MIT License. + * You may find a license copy in project root. + */ + +package etherscan + +import "time" + +// GasEstiamte gets estiamted confirmation time (in seconds) at the given gas price +func (c *Client) GasEstimate(gasPrice int) (confirmationTimeInSec time.Duration, err error) { + params := M{"gasPrice": gasPrice} + var confTime string + err = c.call("gastracker", "gasestimate", params, &confTime) + if err != nil { + return + } + return time.ParseDuration(confTime + "s") +} + +// GasOracle gets suggested gas prices (in Gwei) +func (c *Client) GasOracle() (gasPrices GasPrices, err error) { + err = c.call("gastracker", "gasoracle", M{}, &gasPrices) + return +} diff --git a/gas_tracker_e2e_test.go b/gas_tracker_e2e_test.go new file mode 100644 index 0000000..3905bc3 --- /dev/null +++ b/gas_tracker_e2e_test.go @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018 LI Zhennan + * + * Use of this work is governed by a MIT License. + * You may find a license copy in project root. + */ + +package etherscan + +import ( + "testing" +) + +//GasEstiamte generates dynamic data. Best we can do is ensure all fields are populated +func TestClient_GasEstimate(t *testing.T) { + _, err := api.GasEstimate(20000000) + noError(t, err, "api.GasEstimate") +} + +//GasOracle generates dynamic data. Best we can do is ensure all fields are populated +func TestClient_GasOracle(t *testing.T) { + gasPrice, err := api.GasOracle() + noError(t, err, "api.GasOrcale") + + if 0 == len(gasPrice.GasUsedRatio) { + t.Errorf("gasPrice.GasUsedRatio empty") + } + +} diff --git a/go.mod b/go.mod index 0a30134..5837de6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/nanmu42/etherscan-api go 1.13 + +require github.com/google/go-cmp v0.5.7 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ca3a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/logs.go b/logs.go new file mode 100644 index 0000000..0b21124 --- /dev/null +++ b/logs.go @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Avi Misra + * + * Use of this work is governed by a MIT License. + * You may find a license copy in project root. + */ + +package etherscan + +// GetLogs gets logs that match "topic" emitted by the specified "address" between the "fromBlock" and "toBlock" +func (c *Client) GetLogs(fromBlock, toBlock int, address, topic string) (logs []Log, err error) { + param := M{ + "fromBlock": fromBlock, + "toBlock": toBlock, + "topic0": topic, + "address": address, + } + + err = c.call("logs", "getLogs", param, &logs) + return +} diff --git a/logs_e2e_test.go b/logs_e2e_test.go new file mode 100644 index 0000000..e4490d2 --- /dev/null +++ b/logs_e2e_test.go @@ -0,0 +1,31 @@ +package etherscan + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestClient_GetLogs(t *testing.T) { + expectedLogs := []Log{ + Log{ + Address: "0x33990122638b9132ca29c723bdf037f1a891a70c", + Topics: []string{"0xf63780e752c6a54a94fc52715dbc5518a3b4c3c2833d301a204226548a2a8545", "0x72657075746174696f6e00000000000000000000000000000000000000000000", "0x000000000000000000000000d9b2f59f3b5c7b3c67047d2f03c3e8052470be92"}, + Data: "0x", + BlockNumber: "0x5c958", + BlockHash: "0xe32a9cac27f823b18454e8d69437d2af41a1b81179c6af2601f1040a72ad444b", + TransactionHash: "0x0b03498648ae2da924f961dda00dc6bb0a8df15519262b7e012b7d67f4bb7e83", + LogIndex: "0x", + }, + } + + actualLogs, err := api.GetLogs(379224, 379225, "0x33990122638b9132ca29c723bdf037f1a891a70c", "0xf63780e752c6a54a94fc52715dbc5518a3b4c3c2833d301a204226548a2a8545") + + noError(t, err, "api.GetLogs") + + equal := cmp.Equal(expectedLogs, actualLogs) + + if !equal { + t.Errorf("api.GetLogs not working\n: %s\n", cmp.Diff(expectedLogs, actualLogs)) + } +} diff --git a/network.go b/network.go index 39f0f56..32372ca 100644 --- a/network.go +++ b/network.go @@ -18,6 +18,8 @@ const ( Kovan Network = "api-kovan" // Rinkby Testnet(CLIQUE) Rinkby Network = "api-rinkeby" + // Goerli Testnet(CLIQUE) + Goerli Network = "api-goerli" // Tobalaba Testnet Tobalaba Network = "api-tobalaba" ) @@ -27,6 +29,6 @@ type Network string // SubDomain returns the subdomain of etherscan API // via n provided. -func (n Network) SubDomain() (sub string) { +func (n Network) SubDomain() (sub string) { return string(n) -} \ No newline at end of file +} diff --git a/response.go b/response.go index b1338d3..5c418cf 100644 --- a/response.go +++ b/response.go @@ -7,7 +7,12 @@ package etherscan -import "encoding/json" +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) // Envelope is the carrier of nearly every response type Envelope struct { @@ -45,6 +50,8 @@ type NormalTx struct { CumulativeGasUsed int `json:"cumulativeGasUsed,string"` GasUsed int `json:"gasUsed,string"` Confirmations int `json:"confirmations,string"` + FunctionName string `json:"functionName"` + MethodId string `json:"methodId"` } // InternalTx holds info from internal tx query @@ -88,6 +95,53 @@ type ERC20Transfer struct { Confirmations int `json:"confirmations,string"` } +// ERC721Transfer holds info from ERC721 token transfer event query +type ERC721Transfer struct { + BlockNumber int `json:"blockNumber,string"` + TimeStamp Time `json:"timeStamp"` + Hash string `json:"hash"` + Nonce int `json:"nonce,string"` + BlockHash string `json:"blockHash"` + From string `json:"from"` + ContractAddress string `json:"contractAddress"` + To string `json:"to"` + TokenID *BigInt `json:"tokenID"` + TokenName string `json:"tokenName"` + TokenSymbol string `json:"tokenSymbol"` + TokenDecimal uint8 `json:"tokenDecimal,string"` + TransactionIndex int `json:"transactionIndex,string"` + Gas int `json:"gas,string"` + GasPrice *BigInt `json:"gasPrice"` + GasUsed int `json:"gasUsed,string"` + CumulativeGasUsed int `json:"cumulativeGasUsed,string"` + Input string `json:"input"` + Confirmations int `json:"confirmations,string"` +} + +// ERC1155Transfer holds info from ERC1155 token transfer event query +type ERC1155Transfer struct { + BlockNumber int `json:"blockNumber,string"` + TimeStamp Time `json:"timeStamp"` + Hash string `json:"hash"` + Nonce int `json:"nonce,string"` + BlockHash string `json:"blockHash"` + From string `json:"from"` + ContractAddress string `json:"contractAddress"` + To string `json:"to"` + TokenID *BigInt `json:"tokenID"` + TokenName string `json:"tokenName"` + TokenSymbol string `json:"tokenSymbol"` + TokenDecimal uint8 `json:"tokenDecimal,string"` + TokenValue uint8 `json:"tokenValue,string"` + TransactionIndex int `json:"transactionIndex,string"` + Gas int `json:"gas,string"` + GasPrice *BigInt `json:"gasPrice"` + GasUsed int `json:"gasUsed,string"` + CumulativeGasUsed int `json:"cumulativeGasUsed,string"` + Input string `json:"input"` + Confirmations int `json:"confirmations,string"` +} + // MinedBlock holds info from query for mined block by address type MinedBlock struct { BlockNumber int `json:"blockNumber,string"` @@ -104,7 +158,11 @@ type ContractSource struct { OptimizationUsed int `json:"OptimizationUsed,string"` Runs int `json:"Runs,string"` ConstructorArguments string `json:"ConstructorArguments"` + EVMVersion string `json:"EVMVersion"` Library string `json:"Library"` + LicenseType string `json:"LicenseType"` + Proxy string `json:"Proxy"` + Implementation string `json:"Implementation"` SwarmSource string `json:"SwarmSource"` } @@ -136,3 +194,77 @@ type LatestPrice struct { ETHUSD float64 `json:"ethusd,string"` ETHUSDTimestamp Time `json:"ethusd_timestamp"` } + +type Log struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + TransactionHash string `json:"transactionHash"` + BlockHash string `json:"blockHash"` + LogIndex string `json:"logIndex"` + Removed bool `json:"removed"` +} + +// GasPrices holds info for Gas Oracle queries +// Gas Prices are returned in Gwei +type GasPrices struct { + LastBlock int + SafeGasPrice float64 + ProposeGasPrice float64 + FastGasPrice float64 + SuggestBaseFeeInGwei float64 `json:"suggestBaseFee"` + GasUsedRatio []float64 `json:"gasUsedRatio"` +} + +func (gp *GasPrices) UnmarshalJSON(data []byte) error { + _gp := struct { + LastBlock string + SafeGasPrice string + ProposeGasPrice string + FastGasPrice string + SuggestBaseFeeInGwei string `json:"suggestBaseFee"` + GasUsedRatio string `json:"gasUsedRatio"` + }{} + + err := json.Unmarshal(data, &_gp) + if err != nil { + return err + } + + gp.LastBlock, err = strconv.Atoi(_gp.LastBlock) + if err != nil { + return fmt.Errorf("Unable to convert LastBlock %s to int: %w", _gp.LastBlock, err) + } + + gp.SafeGasPrice, err = strconv.ParseFloat(_gp.SafeGasPrice, 64) + if err != nil { + return fmt.Errorf("Unable to convert SafeGasPrice %s to float64: %w", _gp.SafeGasPrice, err) + } + + gp.ProposeGasPrice, err = strconv.ParseFloat(_gp.ProposeGasPrice, 64) + if err != nil { + return fmt.Errorf("Unable to convert ProposeGasPrice %s to float64: %w", _gp.ProposeGasPrice, err) + } + + gp.FastGasPrice, err = strconv.ParseFloat(_gp.FastGasPrice, 64) + if err != nil { + return fmt.Errorf("Unable to convert FastGasPrice %s to float64: %w", _gp.FastGasPrice, err) + } + + gp.SuggestBaseFeeInGwei, err = strconv.ParseFloat(_gp.SuggestBaseFeeInGwei, 64) + if err != nil { + return fmt.Errorf("Unable to convert SuggestBaseFeeInGwei %s to float64: %w", _gp.SuggestBaseFeeInGwei, err) + } + + gasRatios := strings.Split(_gp.GasUsedRatio, ",") + gp.GasUsedRatio = make([]float64, len(gasRatios)) + for i, gasRatio := range gasRatios { + gp.GasUsedRatio[i], err = strconv.ParseFloat(gasRatio, 64) + if err != nil { + return fmt.Errorf("Unable to convert gasRatio %s to float64: %w", gasRatio, err) + } + } + + return nil +}