Skip to content

Commit

Permalink
feat(cli): get balance for an account (#109)
Browse files Browse the repository at this point in the history
* feat(cli): get the balance of an account in wei

- accept block height as quantity of tag (only "latest" supported for now)
- allow conversion of the output from wei to ether
- accept any compatible rpc endpoint

* test(cli): initial test suite for balance command

* docs(readme): updated with balance command

* refactor(cli): factory pattern

* refactor(cli): const flags and cobra stdout support

* fix(cli): fix output redirection and little refactoring
  • Loading branch information
czar0 authored Jan 6, 2024
1 parent 47213f8 commit 94396f7
Show file tree
Hide file tree
Showing 3 changed files with 211 additions and 4 deletions.
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ Ethkit comes equipped with the `ethkit` CLI providing:
with scrypt wallet encryption support.
- **Abigen** - generate Go code from an ABI artifact file to interact with or deploy a smart
contract.
- **Artifacts** - parse details from a Truffle artifact file from command line such as contract
bytecode or the json abi
- **Artifacts** - parse details from a Truffle artifact file from the command line such as contract
bytecode or the JSON abi.
- **Balance** - retrieve the balance of an account at any block height for any supported network via RPC.

## Install

Expand All @@ -42,8 +43,7 @@ Ethkit comes equipped with the `ethkit` CLI providing:
### wallet

`wallet` handles encrypted Ethereum wallet creation and management in user-supplied keyfiles.
It allows users to create a new Ethereum wallet, import an existing Ethereum wallet from a secret
mnemonic or print an existing wallet's secret mnemonic.
It allows users to create a new Ethereum wallet, import an existing Ethereum wallet from a secret mnemonic, or print an existing wallet's secret mnemonic.

```bash
Usage:
Expand Down Expand Up @@ -92,6 +92,21 @@ Flags:
-h, --help help for artifacts
```

### balance

`balance` retrieves the balance of an account via RPC by a provided address at a predefined block height.

```bash
Usage:
ethkit balance [account] [flags]

Flags:
-B, --block string The block height to query at (default "latest")
-e, --ether Format the balance in ether
-h, --help help for balance
-r, --rpc-url string The RPC endpoint to the blockchain node to interact with
```

## Ethkit Go Development Library

Ethkit is a very capable Ethereum development library for writing systems in Go that
Expand Down
115 changes: 115 additions & 0 deletions cmd/ethkit/balance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"context"
"errors"
"fmt"
"math/big"
"net/url"
"strconv"

"github.com/spf13/cobra"

"github.com/0xsequence/ethkit/ethrpc"
"github.com/0xsequence/ethkit/go-ethereum/common"
"github.com/0xsequence/ethkit/go-ethereum/params"
)

const (
flagBalanceBlock = "block"
flagBalanceEther = "ether"
flagBalanceRpcUrl = "rpc-url"
)

func init() {
rootCmd.AddCommand(NewBalanceCmd())
}

func NewBalanceCmd() *cobra.Command {
c := &balance{}
cmd := &cobra.Command{
Use: "balance [account]",
Short: "Get the balance of an account",
Aliases: []string{"b"},
Args: cobra.ExactArgs(1),
RunE: c.Run,
}

cmd.Flags().StringP(flagBalanceBlock, "B", "latest", "The block height to query at")
cmd.Flags().BoolP(flagBalanceEther, "e", false, "Format the balance in ether")
cmd.Flags().StringP(flagBalanceRpcUrl, "r", "", "The RPC endpoint to the blockchain node to interact with")

return cmd
}

type balance struct {
}

func (c *balance) Run(cmd *cobra.Command, args []string) error {
fAccount := cmd.Flags().Args()[0]
fBlock, err := cmd.Flags().GetString(flagBalanceBlock)
if err != nil {
return err
}
fEther, err := cmd.Flags().GetBool(flagBalanceEther)
if err != nil {
return err
}
fRpc, err := cmd.Flags().GetString(flagBalanceRpcUrl)
if err != nil {
return err
}

if !common.IsHexAddress(fAccount) {
return errors.New("error: please provide a valid account address (e.g. 0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742)")
}

if _, err = url.ParseRequestURI(fRpc); err != nil {
return errors.New("error: please provide a valid rpc url (e.g. https://nodes.sequence.app/mainnet)")
}

provider, err := ethrpc.NewProvider(fRpc)
if err != nil {
return err
}

block, err := strconv.ParseUint(fBlock, 10, 64)
if err != nil {
// TODO: implement support for all tags: earliest, latest, pending, finalized, safe
if fBlock == "latest" {
bh, err := provider.BlockNumber(context.Background())
if err != nil {
return err
}
block = bh
} else {
return errors.New("error: invalid block height")
}
}

wei, err := provider.BalanceAt(context.Background(), common.HexToAddress(fAccount), big.NewInt(int64(block)))
if err != nil {
return err
}

if fEther {
bal := weiToEther(wei)
fmt.Fprintln(cmd.OutOrStdout(), bal, "ether")
} else {
fmt.Fprintln(cmd.OutOrStdout(), wei, "wei")
}

return nil
}

// https://github.com/ethereum/go-ethereum/issues/21221
func weiToEther(wei *big.Int) *big.Float {
f := new(big.Float)
f.SetPrec(236) // IEEE 754 octuple-precision binary floating-point format: binary256
f.SetMode(big.ToNearestEven)
fWei := new(big.Float)
fWei.SetPrec(236) // IEEE 754 octuple-precision binary floating-point format: binary256
fWei.SetMode(big.ToNearestEven)

return f.Quo(fWei.SetInt(wei), big.NewFloat(params.Ether))
}
77 changes: 77 additions & 0 deletions cmd/ethkit/balance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package main

import (
"bytes"
"fmt"
"math"
"strconv"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func execute(args string) (string, error) {
cmd := NewBalanceCmd()
actual := new(bytes.Buffer)
cmd.SetOut(actual)
cmd.SetErr(actual)
cmd.SetArgs(strings.Split(args, " "))
if err := cmd.Execute(); err != nil {
return "", err
}

return actual.String(), nil
}

func Test_BalanceCmd(t *testing.T) {
res, err := execute("0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742 --rpc-url https://nodes.sequence.app/sepolia")
assert.Nil(t, err)
assert.NotNil(t, res)
}

func Test_BalanceCmd_ValidWei(t *testing.T) {
res, err := execute("0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742 --rpc-url https://nodes.sequence.app/sepolia")
assert.Nil(t, err)
assert.Equal(t, res, fmt.Sprintln(strconv.Itoa(500_000_000_000_000_000), "wei"))
}

func Test_BalanceCmd_ValidEther(t *testing.T) {
res, err := execute("0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742 --rpc-url https://nodes.sequence.app/sepolia --ether")
assert.Nil(t, err)
assert.Equal(t, res, fmt.Sprintln(strconv.FormatFloat(0.5, 'f', -1, 64), "ether"))
}

func Test_BalanceCmd_InvalidAddress(t *testing.T) {
res, err := execute("0x1 --rpc-url https://nodes.sequence.app/sepolia")
assert.NotNil(t, err)
assert.Empty(t, res)
assert.Contains(t, err.Error(), "please provide a valid account address")
}

func Test_BalanceCmd_InvalidRPC(t *testing.T) {
res, err := execute("0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742 --rpc-url nodes.sequence.app/sepolia")
assert.NotNil(t, err)
assert.Empty(t, res)
assert.Contains(t, err.Error(), "please provide a valid rpc url")
}

func Test_BalanceCmd_NotExistingBlockHeigh(t *testing.T) {
res, err := execute("0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742 --rpc-url https://nodes.sequence.app/sepolia --block " + fmt.Sprint(math.MaxInt64))
assert.NotNil(t, err)
assert.Empty(t, res)
assert.Contains(t, err.Error(), "jsonrpc error -32000: header not found")
}

func Test_BalanceCmd_NotAValidStringBlockHeigh(t *testing.T) {
res, err := execute("0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742 --rpc-url https://nodes.sequence.app/sepolia --block something")
assert.NotNil(t, err)
assert.Empty(t, res)
assert.Contains(t, err.Error(), "invalid block height")
}

func Test_BalanceCmd_NotAValidNumberBlockHeigh(t *testing.T) {
res, err := execute("0x213a286A1AF3Ac010d4F2D66A52DeAf762dF7742 --rpc-url https://nodes.sequence.app/sepolia --block -100")
assert.NotNil(t, err)
assert.Empty(t, res)
}

0 comments on commit 94396f7

Please sign in to comment.