├── main.go
├── internal
├── view
│ ├── format
│ │ ├── time.go
│ │ ├── time_test.go
│ │ └── string.go
│ ├── style
│ │ ├── style_test.go
│ │ ├── theme.go
│ │ └── style.go
│ ├── util
│ │ ├── keys.go
│ │ ├── table.go
│ │ ├── spinner.go
│ │ ├── loader.go
│ │ ├── key.go
│ │ └── avatar.go
│ ├── help.go
│ ├── home.go
│ ├── helper.go
│ ├── signer.go
│ ├── txn_preview.go
│ ├── notification.go
│ ├── chain_info.go
│ ├── app.go
│ ├── signin.go
│ ├── query.go
│ ├── import_abi.go
│ ├── account.go
│ ├── transfer.go
│ ├── transaction.go
│ ├── root.go
│ ├── transactions.go
│ └── method_call.go
├── common
│ ├── types_test.go
│ ├── types.go
│ ├── conv
│ │ ├── hex.go
│ │ ├── ether.go
│ │ └── argument.go
│ ├── log.go
│ └── transaction.go
├── service
│ ├── cache.go
│ ├── service_test.go
│ ├── types.go
│ ├── signer_test.go
│ ├── signer.go
│ ├── account.go
│ ├── contract.go
│ ├── syncer.go
│ └── service.go
├── provider
│ ├── alchemy_test.go
│ ├── etherscan
│ │ ├── etherscan_test.go
│ │ ├── types.go
│ │ └── etherscan.go
│ ├── alchemy.go
│ ├── etherum_test.go
│ └── ethereum.go
└── config
│ └── config.go
├── .gitignore
├── cmd
├── version.go
└── root.go
├── .goreleaser.yaml
├── go.mod
├── README.md
├── LICENSE
└── go.sum
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "github.com/dyng/ramen/cmd"
4 |
5 | func main() {
6 | cmd.Execute()
7 | }
8 |
--------------------------------------------------------------------------------
/internal/view/format/time.go:
--------------------------------------------------------------------------------
1 | package format
2 |
3 | import "time"
4 |
5 | func ToDatetime(sec uint64) string {
6 | return time.Unix(int64(sec), 0).Format("2006-01-02 15:04:05")
7 | }
8 |
--------------------------------------------------------------------------------
/internal/view/format/time_test.go:
--------------------------------------------------------------------------------
1 | package format
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestToDatetime(t *testing.T) {
10 | assert.Equal(t, "2023-01-06 15:09:27", ToDatetime(1672988967))
11 | }
12 |
--------------------------------------------------------------------------------
/internal/view/style/style_test.go:
--------------------------------------------------------------------------------
1 | package style
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/gdamore/tcell/v2"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestColor(t *testing.T) {
11 | result := Color("hello", tcell.ColorBlue)
12 | assert.Equal(t, "[#0000ff::]hello[-::]", result, "should be colorized text")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/common/types_test.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ethereum/go-ethereum/common"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestAddress_NoError(t *testing.T) {
11 | addr := common.HexToAddress("0xFABB0ac9d68B0B445fB7357272Ff202C5651694a")
12 | assert.Equal(t, "0xFABB0ac9d68B0B445fB7357272Ff202C5651694a", addr.Hex())
13 | }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Go workspace file
15 | go.work
16 |
17 | # IdeaIDE
18 | .idea
19 |
20 | # VS Code
21 | .vscode
22 |
23 | # macOS
24 | .DS_Store
25 | .AppleDouble
26 | .LSOverride
27 |
28 | # logs
29 | *.log
30 |
31 | dist/
32 |
--------------------------------------------------------------------------------
/cmd/version.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/spf13/cobra"
7 | )
8 |
9 | var Version = "development"
10 | var Commit = "development"
11 |
12 | func versionCmd() *cobra.Command {
13 | return &cobra.Command{
14 | Use: "version",
15 | Short: "Print version number",
16 | Long: "Print version number",
17 | Run: func(cmd *cobra.Command, args []string) {
18 | fmt.Printf("Version: %s, Build Commit: %s", Version, Commit)
19 | },
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/internal/common/types.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "math/big"
5 |
6 | "github.com/ethereum/go-ethereum/common"
7 | "github.com/ethereum/go-ethereum/core/types"
8 | )
9 |
10 | // BigInt is used everywhere in Ethereum
11 | type BigInt = *big.Int
12 |
13 | // Address is an alias for geth Address
14 | type Address = common.Address
15 |
16 | // Block is an alias for geth Block
17 | type Block = types.Block
18 |
19 | // Hash is an alias for geth Hash
20 | type Hash = common.Hash
21 |
22 | // Header is an alias for geth Header
23 | type Header = types.Header
24 |
--------------------------------------------------------------------------------
/internal/service/cache.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/dyng/ramen/internal/common"
7 | )
8 |
9 | func (s *Service) SetCache(address common.Address, accountType AccountType, value any, expiration time.Duration) {
10 | s.cache.Set(s.cacheKey(address, accountType), value, expiration)
11 | }
12 |
13 | func (s *Service) GetCache(address common.Address, accountType AccountType) (any, bool) {
14 | return s.cache.Get(s.cacheKey(address, accountType))
15 | }
16 |
17 | func (s *Service) cacheKey(address common.Address, accountType AccountType) string {
18 | chainId := s.GetNetwork().ChainId
19 | return chainId.String() + ":" + address.Hex() + ":" + accountType.String()
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/internal/common/conv/hex.go:
--------------------------------------------------------------------------------
1 | package conv
2 |
3 | import (
4 | "encoding/hex"
5 | "strconv"
6 | )
7 |
8 | // HexToInt converts string format of a hex value to int64.
9 | func HexToInt(s string) (int64, error) {
10 | return strconv.ParseInt(Trim0xPrefix(s), 16, 64)
11 | }
12 |
13 | // HexToInt converts string format of a series of hex value to byte slice.
14 | func HexToBytes(s string) ([]byte, error) {
15 | return hex.DecodeString(Trim0xPrefix(s))
16 | }
17 |
18 | // Trim0xPrefix removes '0x' prefix if any
19 | func Trim0xPrefix(s string) string {
20 | if has0xPrefix(s) {
21 | return s[2:]
22 | } else {
23 | return s
24 | }
25 | }
26 |
27 | func has0xPrefix(s string) bool {
28 | return len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')
29 | }
30 |
--------------------------------------------------------------------------------
/internal/common/conv/ether.go:
--------------------------------------------------------------------------------
1 | package conv
2 |
3 | import (
4 | "math/big"
5 |
6 | "github.com/ethereum/go-ethereum/params"
7 | )
8 |
9 | // ToEther converts values in wei to ether.
10 | func ToEther(wei *big.Int) *big.Float {
11 | return new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(params.Ether))
12 | }
13 |
14 | // ToGwei converts values in wei to Gwei.
15 | func ToGwei(wei *big.Int) *big.Int {
16 | return new(big.Int).Quo(wei, big.NewInt(params.GWei))
17 | }
18 |
19 | // FromEther converts values in Ether to wei.
20 | func FromEther(n *big.Float) *big.Int {
21 | i, _ := new(big.Float).Mul(n, big.NewFloat(params.Ether)).Int(nil)
22 | return i
23 | }
24 |
25 | // FromGwei converts values in Gwei to wei.
26 | func FromGwei(n *big.Float) *big.Int {
27 | i, _ := new(big.Float).Mul(n, big.NewFloat(params.GWei)).Int(nil)
28 | return i
29 | }
30 |
--------------------------------------------------------------------------------
/internal/common/log.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/ethereum/go-ethereum/log"
8 | )
9 |
10 | // Exit prints the message to stderr and exits with status 1
11 | func Exit(msg string, args ...any) {
12 | fmt.Fprintf(os.Stderr, msg+"\n", args...)
13 | os.Exit(1)
14 | }
15 |
16 | // PrintMessage prints a message to the console (i.e. stdout)
17 | func PrintMessage(msg string, args ...any) {
18 | fmt.Printf(msg+"\n", args...)
19 | }
20 |
21 | // ErrorStackHandler is a log handler that prints the stack trace of an error
22 | func ErrorStackHandler(h log.Handler) log.Handler {
23 | return log.FuncHandler(func(r *log.Record) error {
24 | i := len(r.Ctx) - 1
25 | if i > 0 {
26 | e := r.Ctx[i]
27 | if err, ok := e.(error); ok {
28 | r.Ctx[i] = fmt.Sprintf("%+v", err)
29 | }
30 | }
31 | return h.Log(r)
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/internal/provider/alchemy_test.go:
--------------------------------------------------------------------------------
1 | package provider
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestGetAssetTransfers_NoError(t *testing.T) {
10 | // prepare
11 | provider := NewProvider(testAlchemyEndpoint, ProviderAlchemy)
12 |
13 | // process
14 | params := GetAssetTransfersParams{
15 | ToAddress: "0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae",
16 | ContractAddresses: []string{},
17 | Category: []string{"external"},
18 | Order: "asc",
19 | MaxCount: "0xA",
20 | }
21 | result, err := provider.GetAssetTransfers(params)
22 |
23 | // verify
24 | assert.NoError(t, err)
25 | assert.NotNil(t, result.PageKey, "page key should not be nil")
26 | assert.Len(t, result.Transfers, 10, "should contain 10 transfers")
27 |
28 | transfer := result.Transfers[0]
29 | assert.NotEmpty(t, transfer.Hash, "hash should not be nil")
30 | assert.NotEmpty(t, transfer.From, "sender should not be nil")
31 | assert.NotEmpty(t, transfer.To, "receiver should not be nil")
32 | }
33 |
--------------------------------------------------------------------------------
/internal/view/format/string.go:
--------------------------------------------------------------------------------
1 | package format
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/dyng/ramen/internal/common"
7 | )
8 |
9 | func BytesToString(b []byte, limit int) string {
10 | return TruncateText(fmt.Sprintf("%x", b), limit)
11 | }
12 |
13 | func TruncateText(text string, limit int) string {
14 | if len(text) > limit {
15 | return text[:limit] + "..."
16 | } else {
17 | return text
18 | }
19 | }
20 |
21 | func NormalizeReceiverAddress(receiver *common.Address) string {
22 | if receiver == nil {
23 | return "0x0"
24 | } else {
25 | return receiver.Hex()
26 | }
27 | }
28 |
29 | func FineErrorMessage(msg string, args ...any) string {
30 | if len(args) == 0 {
31 | return msg
32 | }
33 |
34 | message := ""
35 | last := len(args) - 1
36 | err, ok := args[last].(error)
37 | if ok {
38 | message = fmt.Sprintf(msg, args[:last]...)
39 | message += fmt.Sprintf("\n\nError:\n%s", err)
40 | message += "\n\nPlease check the log files for more details."
41 | } else {
42 | message = fmt.Sprintf(msg, args...)
43 | }
44 |
45 | return message
46 | }
47 |
--------------------------------------------------------------------------------
/internal/view/util/keys.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | )
6 |
7 | const (
8 | NAValue = "[dimgray]n/a[-]"
9 |
10 | EmptyValue = ""
11 | )
12 |
13 | type (
14 | KeyHandler func(*tcell.EventKey)
15 |
16 | KeyMap struct {
17 | Key tcell.Key
18 | Shortcut string
19 | Description string
20 | Handler KeyHandler
21 | }
22 |
23 | KeyMaps []KeyMap
24 | )
25 |
26 | // NewSimpleKey creates a simple keymap with only key and handler.
27 | func NewSimpleKey(key tcell.Key, handler func()) KeyMap {
28 | return KeyMap{
29 | Key: key,
30 | Handler: func(*tcell.EventKey) { handler() },
31 | }
32 | }
33 |
34 | func (km KeyMaps) Add(another KeyMaps) KeyMaps {
35 | return append(km, another...)
36 | }
37 |
38 | func (km KeyMaps) FindHandler(key tcell.Key) (KeyHandler, bool) {
39 | for _, keymap := range km {
40 | if keymap.Key == key {
41 | return keymap.Handler, true
42 | }
43 | }
44 | return nil, false
45 | }
46 |
47 | // AsKey converts rune to keyboard key.
48 | func AsKey(evt *tcell.EventKey) tcell.Key {
49 | if evt.Key() != tcell.KeyRune {
50 | return evt.Key()
51 | }
52 | return tcell.Key(evt.Rune())
53 | }
54 |
--------------------------------------------------------------------------------
/internal/service/service_test.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dyng/ramen/internal/config"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestGetNetwork(t *testing.T) {
11 | // prepare
12 | serv := NewTestService()
13 |
14 | // process
15 | network := serv.GetNetwork()
16 |
17 | // verify
18 | assert.Equal(t, "31337", network.ChainId.String(), "chain id should be 31337")
19 | assert.Equal(t, "GoChain Testnet", network.Name, "chain name should be GoChain Testnet")
20 | }
21 |
22 | func TestGetSigner(t *testing.T) {
23 | // prepare
24 | serv := NewTestService()
25 |
26 | // process
27 | privateKey := "0xde9be858da4a475276426320d5e9262ecfc3ba460bfac56360bfa6c4c28b4ee0"
28 | signer, err := serv.GetSigner(privateKey)
29 |
30 | // verify
31 | assert.NoError(t, err)
32 | assert.Equal(t, signer.GetAddress().Hex(), "0xdD2FD4581271e230360230F9337D5c0430Bf44C0", "signer's account address should be correct")
33 | assert.NotNil(t, signer.PrivateKey, "signer should have private key")
34 | }
35 |
36 | func NewTestService() *Service {
37 | config := &config.Config{
38 | DebugMode: true,
39 | Provider: "local",
40 | Network: "mainnet",
41 | }
42 | return NewService(config)
43 | }
44 |
--------------------------------------------------------------------------------
/internal/service/types.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "math/big"
5 | "strings"
6 | )
7 |
8 | const (
9 | // TypeMainnet is the Ethereum Mainnet
10 | TypeMainnet = "mainnet"
11 | // TypeTestnet is all kinds of the testnets (Ropsten, Rinkeby, Goerli etc.)
12 | TypeTestnet = "testnet"
13 | // TypeDevnet is a local network for development purpose (Hardhat, Ganeche etc.)
14 | TypeDevnet = "devnet"
15 | // TypeUnknown is a unknown network
16 | TypeUnknown = "unknown"
17 | )
18 |
19 | type Network struct {
20 | Name string `json:"name"`
21 | Title string `json:"title"`
22 | ChainId *big.Int `json:"chainId"`
23 | }
24 |
25 | // NetType returns type of this network.
26 | //
27 | // There are 3 types of network:
28 | // - Mainnet: a public network for serious applications
29 | // - Testnet: a public network for testing
30 | // - Devnet: a local network for development purpose
31 | func (n Network) NetType() string {
32 | if n.Name == "Ethereum Mainnet" {
33 | return TypeMainnet
34 | }
35 |
36 | if strings.Contains(n.Title, "Testnet") {
37 | return TypeTestnet
38 | }
39 |
40 | if n.ChainId.String() == "1337" || n.ChainId.String() == "31337" {
41 | return TypeDevnet
42 | }
43 |
44 | return TypeUnknown
45 | }
46 |
--------------------------------------------------------------------------------
/internal/view/help.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/dyng/ramen/internal/view/style"
7 | "github.com/dyng/ramen/internal/view/util"
8 | "github.com/rivo/tview"
9 | )
10 |
11 | type Help struct {
12 | *tview.Table
13 | app *App
14 |
15 | keymaps util.KeyMaps
16 | }
17 |
18 | func NewHelp(app *App) *Help {
19 | help := &Help{
20 | Table: tview.NewTable(),
21 | app: app,
22 | }
23 |
24 | return help
25 | }
26 |
27 | func (h *Help) SetKeyMaps(keymaps util.KeyMaps) {
28 | h.keymaps = keymaps
29 | h.refresh()
30 | }
31 |
32 | func (h *Help) AddKeyMaps(keymaps util.KeyMaps) {
33 | h.keymaps.Add(keymaps)
34 | h.refresh()
35 | }
36 |
37 | func (h *Help) Clear() {
38 | for i := h.GetRowCount() - 1; i > 0; i-- {
39 | h.RemoveRow(i)
40 | }
41 | }
42 |
43 | func (h *Help) refresh() {
44 | // clear previous content at first
45 | h.Clear()
46 |
47 | s := h.app.config.Style()
48 | row, col := 0, 0
49 | for _, keymap := range h.keymaps {
50 | if row >= style.HeaderHeight {
51 | row = 0
52 | col += 2
53 | }
54 |
55 | short := fmt.Sprintf("<%s>", keymap.Shortcut)
56 | desc := keymap.Description
57 | sec := util.NewSectionWithColor(short, s.HelpKeyColor, desc, s.FgColor)
58 | sec.AddToTable(h.Table, row, col)
59 |
60 | row += 1
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/internal/service/signer_test.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "math/big"
5 | "testing"
6 |
7 | "github.com/dyng/ramen/internal/common/conv"
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestTransfer(t *testing.T) {
12 | // prepare
13 | serv := NewTestService()
14 |
15 | // sender
16 | senderKey := "0xde9be858da4a475276426320d5e9262ecfc3ba460bfac56360bfa6c4c28b4ee0"
17 | sender, _ := serv.GetSigner(senderKey)
18 | balance, _ := sender.GetBalanceForce()
19 | assert.EqualValues(t, conv.FromEther(big.NewFloat(10000)), balance, "sender should have 10000 eth")
20 |
21 | // receiver
22 | receiver, _ := serv.GetAccount("0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199")
23 | balance, _ = receiver.GetBalanceForce()
24 | assert.EqualValues(t, conv.FromEther(big.NewFloat(10000)), balance, "receiver should have 10000 eth")
25 |
26 | // process
27 | _, err := sender.TransferTo(receiver.GetAddress(), conv.FromEther(big.NewFloat(1000)))
28 |
29 | // verify
30 | assert.NoError(t, err)
31 | balance, _ = sender.GetBalanceForce()
32 | assert.LessOrEqual(t, balance.Cmp(conv.FromEther(big.NewFloat(9000))), 0, "sender should have less than 9990 eth")
33 | balance, _ = receiver.GetBalanceForce()
34 | assert.EqualValues(t, conv.FromEther(big.NewFloat(11000)), balance, "receiver should have 10010 eth")
35 | }
36 |
--------------------------------------------------------------------------------
/internal/view/style/theme.go:
--------------------------------------------------------------------------------
1 | package style
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/rivo/tview"
6 | )
7 |
8 | // Palette
9 | //
10 | // Color60: #5F5F87
11 | // Color69: #5F87FF
12 | // Color73: #5FAFAF
13 | // Color147: #AFAFFF
14 | // ColorCoral: #FF7F50
15 | // ColorDimGray: #696969
16 | // ColorSandyBrown: #F4A460
17 | var Ethereum = &Style{
18 | FgColor: tcell.ColorFloralWhite,
19 | BgColor: tview.Styles.PrimitiveBackgroundColor,
20 | SectionColor: tcell.ColorCoral,
21 | SectionColor2: tcell.Color73,
22 | HelpKeyColor: tcell.Color69,
23 | TitleColor: tcell.ColorFloralWhite,
24 | BorderColor: tcell.Color60,
25 | TitleColor2: tcell.ColorFloralWhite,
26 | BorderColor2: tcell.Color60,
27 | MethResultBorderColor: tcell.Color69,
28 | TableHeaderStyle: new(tcell.Style).Foreground(tcell.Color147).Bold(true),
29 | DialogBgColor: tview.Styles.PrimitiveBackgroundColor,
30 | DialogBorderColor: tcell.Color147,
31 | ButtonBgColor: tcell.ColorCoral,
32 | PrgBarCellColor: tcell.ColorCoral,
33 | PrgBarTitleColor: tcell.ColorFloralWhite,
34 | PrgBarBorderColor: tcell.ColorDimGray,
35 | InputFieldLableColor: tcell.ColorSandyBrown,
36 | InputFieldBgColor: tcell.Color60,
37 | }
38 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | before:
2 | hooks:
3 | - go mod tidy
4 | - go generate ./...
5 | builds:
6 | - env:
7 | - CGO_ENABLED=0
8 | goos:
9 | - linux
10 | - darwin
11 | ldflags:
12 | - -s -w -X github.com/dyng/ramen/cmd.Version=v{{.Version}} -X github.com/dyng/ramen/cmd.Commit={{.Commit}}
13 |
14 | archives:
15 | - format: tar.gz
16 | # this name template makes the OS and Arch compatible with the results of uname.
17 | name_template: >-
18 | {{ .ProjectName }}_
19 | {{- title .Os }}_
20 | {{- if eq .Arch "amd64" }}x86_64
21 | {{- else if eq .Arch "386" }}i386
22 | {{- else }}{{ .Arch }}{{ end }}
23 | {{- if .Arm }}v{{ .Arm }}{{ end }}
24 | # use zip for windows archives
25 | format_overrides:
26 | - goos: windows
27 | format: zip
28 | checksum:
29 | name_template: 'checksums.txt'
30 | snapshot:
31 | name_template: "{{ incpatch .Version }}-next"
32 | changelog:
33 | sort: asc
34 | filters:
35 | exclude:
36 | - '^docs:'
37 | - '^test:'
38 |
39 | # Homebrew
40 | brews:
41 | - name: ramen
42 | tap:
43 | owner: dyng
44 | name: homebrew-ramen
45 | folder: Formula
46 | description: A graphic CLI for interaction with Ethereum easily and happily.
47 | test: |
48 | system "ramen version"
49 |
50 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
51 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
52 |
--------------------------------------------------------------------------------
/internal/view/style/style.go:
--------------------------------------------------------------------------------
1 | package style
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/gdamore/tcell/v2"
7 | )
8 |
9 | const (
10 | // HeaderHeight is the height of header
11 | HeaderHeight = 5
12 | // AvatarSize is the width and height of an avatar
13 | AvatarSize = 4
14 | )
15 |
16 | type Style struct {
17 | // main
18 | FgColor tcell.Color
19 | BgColor tcell.Color
20 | SectionColor tcell.Color
21 | SectionColor2 tcell.Color
22 |
23 | // help
24 | HelpKeyColor tcell.Color
25 |
26 | // body
27 | TitleColor tcell.Color
28 | BorderColor tcell.Color
29 | TitleColor2 tcell.Color
30 | BorderColor2 tcell.Color
31 |
32 | // methodCall
33 | MethResultBorderColor tcell.Color
34 |
35 | // table
36 | TableHeaderStyle tcell.Style
37 |
38 | // dialog
39 | DialogBgColor tcell.Color
40 | DialogBorderColor tcell.Color
41 | ButtonBgColor tcell.Color
42 |
43 | // progress bar
44 | PrgBarCellColor tcell.Color
45 | PrgBarTitleColor tcell.Color
46 | PrgBarBorderColor tcell.Color
47 |
48 | // others
49 | InputFieldLableColor tcell.Color
50 | InputFieldBgColor tcell.Color
51 | }
52 |
53 | func Bold(s string) string {
54 | return fmt.Sprintf("[::b]%s[::-]", s)
55 | }
56 |
57 | func Padding(s string) string {
58 | return fmt.Sprintf(" %s ", s)
59 | }
60 |
61 | func BoldPadding(s string) string {
62 | return fmt.Sprintf(" [::b]%s[::-] ", s)
63 | }
64 |
65 | func Color(s string, color tcell.Color) string {
66 | return fmt.Sprintf("[#%06x::]%s[-::]", color.Hex(), s)
67 | }
68 |
--------------------------------------------------------------------------------
/internal/view/home.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/common"
5 | "github.com/dyng/ramen/internal/service"
6 | "github.com/dyng/ramen/internal/view/util"
7 | "github.com/ethereum/go-ethereum/log"
8 | "github.com/rivo/tview"
9 | )
10 |
11 | type Home struct {
12 | *tview.Flex
13 | app *App
14 |
15 | transactionList *TransactionList
16 | }
17 |
18 | func NewHome(app *App) *Home {
19 | home := &Home{
20 | app: app,
21 | }
22 |
23 | // setup layout
24 | home.initLayout()
25 |
26 | // subscribe to new blocks
27 | app.eventBus.Subscribe(service.TopicNewBlock, home.onNewBlock)
28 |
29 | return home
30 | }
31 |
32 | func (h *Home) initLayout() {
33 | s := h.app.config.Style()
34 |
35 | // Transactions
36 | transactions := NewTransactionList(h.app, false)
37 | transactions.SetBorderColor(s.BorderColor)
38 | transactions.SetTitleColor(s.TitleColor)
39 | h.transactionList = transactions
40 |
41 | // Root
42 | flex := tview.NewFlex()
43 | flex.AddItem(transactions, 0, 1, true)
44 | h.Flex = flex
45 | }
46 |
47 | // KeyMaps implements bodyPage
48 | func (h *Home) KeyMaps() util.KeyMaps {
49 | return h.transactionList.KeyMaps()
50 | }
51 |
52 | func (h *Home) onNewBlock(block *common.Block) {
53 | txns, err := h.app.service.GetTransactionsByBlock(block)
54 | if err != nil {
55 | log.Error("cannot extract transactions from block", "blockHash", block.Hash(), "error", err)
56 | return
57 | }
58 |
59 | h.app.QueueUpdateDraw(func() {
60 | h.transactionList.PrependTransactions(txns)
61 | })
62 | }
63 |
--------------------------------------------------------------------------------
/internal/service/signer.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "crypto/ecdsa"
5 |
6 | "github.com/dyng/ramen/internal/common"
7 | "github.com/ethereum/go-ethereum/accounts/abi"
8 | "github.com/ethereum/go-ethereum/params"
9 | "github.com/pkg/errors"
10 | )
11 |
12 | type Signer struct {
13 | *Account
14 | PrivateKey *ecdsa.PrivateKey
15 | }
16 |
17 | func (s *Signer) TransferTo(address common.Address, amount common.BigInt) (common.Hash, error) {
18 | gasPrice, err := s.service.provider.GetGasPrice()
19 | if err != nil {
20 | return common.Hash{}, err
21 | }
22 |
23 | txnReq := &common.TxnRequest{
24 | PrivateKey: s.PrivateKey,
25 | To: &address,
26 | Value: amount,
27 | GasLimit: params.TxGas,
28 | GasPrice: gasPrice,
29 | }
30 |
31 | return s.service.provider.SendTransaction(txnReq)
32 | }
33 |
34 | func (s *Signer) CallContract(address common.Address, abi *abi.ABI, method string, args ...any) (common.Hash, error) {
35 | gasPrice, err := s.service.provider.GetGasPrice()
36 | if err != nil {
37 | return common.Hash{}, err
38 | }
39 |
40 | input, err := abi.Pack(method, args...)
41 | if err != nil {
42 | return common.Hash{}, errors.WithStack(err)
43 | }
44 |
45 | gasLimit, err := s.service.provider.EstimateGas(address, s.address, input)
46 | if err != nil {
47 | return common.Hash{}, err
48 | }
49 |
50 | txnReq := &common.TxnRequest{
51 | PrivateKey: s.PrivateKey,
52 | To: &address,
53 | GasLimit: gasLimit,
54 | GasPrice: gasPrice,
55 | Data: input,
56 | }
57 |
58 | return s.service.provider.SendTransaction(txnReq)
59 | }
60 |
--------------------------------------------------------------------------------
/internal/provider/etherscan/etherscan_test.go:
--------------------------------------------------------------------------------
1 | package etherscan
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ethereum/go-ethereum/common"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | const (
11 | testEtherscanEndpoint = "https://api.etherscan.io/api"
12 | testEtherscanApiKey = "IQVJUFHSK9SG8SVDK3MKPIJHQR3137GCPQ"
13 |
14 | usdtContractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
15 | )
16 |
17 | func TestAccountTxList_NoError(t *testing.T) {
18 | // prepare
19 | ec := NewEtherscanClient(testEtherscanEndpoint, testEtherscanApiKey)
20 |
21 | // process
22 | txns, err := ec.AccountTxList(common.HexToAddress("0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae"))
23 |
24 | // verify
25 | assert.NoError(t, err)
26 | assert.NotEmpty(t, txns, "should not empty")
27 |
28 | txn := txns[0]
29 | assert.NotNil(t, txn.BlockNumber(), "block number should not be nil")
30 | assert.NotNil(t, txn.Hash(), "hash should not be nil")
31 | assert.NotNil(t, txn.From(), "sender should not be nil")
32 | assert.NotNil(t, txn.To(), "receiver should not be nil")
33 | assert.NotEmpty(t, txn.Timestamp(), "timestamp should not be nil")
34 | }
35 |
36 | func TestGetSourceCode_NoError(t *testing.T) {
37 | // prepare
38 | ec := NewEtherscanClient(testEtherscanEndpoint, testEtherscanApiKey)
39 |
40 | // process
41 | source, abi, err := ec.GetSourceCode(common.HexToAddress(usdtContractAddress))
42 |
43 | // verify
44 | assert.NoError(t, err)
45 | assert.NotEmpty(t, source, "source code should not be empty")
46 | assert.NotNil(t, abi, "abi should not be null")
47 | assert.Contains(t, abi.Methods, "balanceOf", "should contains method balanceOf")
48 | }
49 |
--------------------------------------------------------------------------------
/internal/view/util/table.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/view/style"
5 | "github.com/gdamore/tcell/v2"
6 | "github.com/rivo/tview"
7 | )
8 |
9 | type Section struct {
10 | titleCell *tview.TableCell
11 | textCell *tview.TableCell
12 | }
13 |
14 | func NewSection(title string, text string) *Section {
15 | return NewSectionWithColor(title, tcell.ColorDefault, text, tcell.ColorDefault)
16 | }
17 |
18 | func NewSectionWithStyle(title string, text string, style *style.Style) *Section {
19 | return NewSectionWithColor(title, style.SectionColor, text, style.FgColor)
20 | }
21 |
22 | func NewSectionWithColor(title string, titleColor tcell.Color, text string, textColor tcell.Color) *Section {
23 | // initialize a title cell
24 | titleCell := tview.NewTableCell(title)
25 | titleCell.SetAlign(tview.AlignLeft).
26 | SetTextColor(titleColor)
27 |
28 | // initialize a text cell
29 | textCell := tview.NewTableCell(text)
30 | textCell.SetAlign(tview.AlignLeft).
31 | SetExpansion(1).
32 | SetTextColor(textColor)
33 |
34 | return &Section{
35 | titleCell: titleCell,
36 | textCell: textCell,
37 | }
38 | }
39 |
40 | func (s *Section) GetTitleCell() *tview.TableCell {
41 | return s.titleCell
42 | }
43 |
44 | func (s *Section) GetTextCell() *tview.TableCell {
45 | return s.textCell
46 | }
47 |
48 | func (s *Section) GetText() string {
49 | return s.textCell.Text
50 | }
51 |
52 | func (s *Section) SetText(text string) {
53 | s.textCell.SetText(text)
54 | }
55 |
56 | func (s *Section) AddToTable(table *tview.Table, row, column int) {
57 | table.SetCell(row, column, s.titleCell)
58 | table.SetCell(row, column+1, s.textCell)
59 | }
60 |
--------------------------------------------------------------------------------
/internal/common/transaction.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "crypto/ecdsa"
5 |
6 | "github.com/ethereum/go-ethereum/common"
7 | "github.com/ethereum/go-ethereum/core/types"
8 | )
9 |
10 | // Transaction represents an Ethereum transaction.
11 | type Transaction interface {
12 | BlockNumber() BigInt
13 |
14 | Hash() Hash
15 |
16 | From() *Address
17 |
18 | To() *Address
19 |
20 | Value() BigInt
21 |
22 | Timestamp() uint64
23 |
24 | Data() []byte
25 | }
26 |
27 | // TxnRequest represents a transaction to be submitted for execution
28 | type TxnRequest struct {
29 | PrivateKey *ecdsa.PrivateKey
30 | To *Address
31 | Value BigInt
32 | Data []byte
33 | GasLimit uint64
34 | GasPrice BigInt
35 | }
36 |
37 | // WrappedTransaction is a wrapper around geth Transaction for convenience
38 | type WrappedTransaction struct {
39 | *types.Transaction
40 | from *common.Address
41 | blockNumber BigInt
42 | timestamp uint64
43 | }
44 |
45 | type Transactions = []Transaction
46 |
47 | func WrapTransaction(txn *types.Transaction, blockNumber BigInt, from *common.Address, timestamp uint64) Transaction {
48 | return &WrappedTransaction{
49 | Transaction: txn,
50 | from: from,
51 | blockNumber: blockNumber,
52 | timestamp: timestamp,
53 | }
54 | }
55 |
56 | func WrapTransactionWithBlock(txn *types.Transaction, block *types.Block, sender *common.Address) Transaction {
57 | return &WrappedTransaction{
58 | Transaction: txn,
59 | from: sender,
60 | blockNumber: block.Number(),
61 | timestamp: block.Time(),
62 | }
63 | }
64 |
65 | func (t *WrappedTransaction) From() *Address {
66 | return t.from
67 | }
68 |
69 | func (t *WrappedTransaction) BlockNumber() BigInt {
70 | return t.blockNumber
71 | }
72 |
73 | func (t *WrappedTransaction) Timestamp() uint64 {
74 | return t.timestamp
75 | }
76 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/dyng/ramen
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
7 | github.com/ethereum/go-ethereum v1.10.26
8 | github.com/gdamore/tcell/v2 v2.5.4
9 | github.com/patrickmn/go-cache v2.1.0+incompatible
10 | github.com/pkg/errors v0.9.1
11 | github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da
12 | github.com/rrivera/identicon v0.0.0-20180626043057-7875f45b0022
13 | github.com/shopspring/decimal v1.3.1
14 | github.com/spf13/cobra v1.6.1
15 | github.com/stretchr/testify v1.8.1
16 | )
17 |
18 | require (
19 | github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect
20 | github.com/davecgh/go-spew v1.1.1 // indirect
21 | github.com/deckarep/golang-set v1.8.0 // indirect
22 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
23 | github.com/gdamore/encoding v1.0.0 // indirect
24 | github.com/go-ole/go-ole v1.2.6 // indirect
25 | github.com/go-stack/stack v1.8.1 // indirect
26 | github.com/gorilla/websocket v1.5.0 // indirect
27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
28 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
29 | github.com/mattn/go-colorable v0.1.13 // indirect
30 | github.com/mattn/go-isatty v0.0.17 // indirect
31 | github.com/mattn/go-runewidth v0.0.14 // indirect
32 | github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 // indirect
33 | github.com/pmezard/go-difflib v1.0.0 // indirect
34 | github.com/rivo/uniseg v0.4.3 // indirect
35 | github.com/shirou/gopsutil v3.21.11+incompatible // indirect
36 | github.com/spf13/pflag v1.0.5 // indirect
37 | github.com/tklauser/go-sysconf v0.3.11 // indirect
38 | github.com/tklauser/numcpus v0.6.0 // indirect
39 | github.com/yusufpapurcu/wmi v1.2.2 // indirect
40 | golang.org/x/crypto v0.4.0 // indirect
41 | golang.org/x/sys v0.4.0 // indirect
42 | golang.org/x/term v0.4.0 // indirect
43 | golang.org/x/text v0.6.0 // indirect
44 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
45 | gopkg.in/yaml.v3 v3.0.1 // indirect
46 | )
47 |
--------------------------------------------------------------------------------
/internal/provider/alchemy.go:
--------------------------------------------------------------------------------
1 | package provider
2 |
3 | import "github.com/pkg/errors"
4 |
5 | type (
6 | GetAssetTransfersParams struct {
7 | FromBlock string `json:"fromBlock,omitempty"`
8 | ToBlock string `json:"toBlock,omitempty"`
9 | FromAddress string `json:"fromAddress,omitempty"`
10 | ToAddress string `json:"toAddress,omitempty"`
11 | ContractAddresses []string `json:"contractAddresses,omitempty"`
12 | Category []string `json:"category,omitempty"`
13 | Order string `json:"order,omitempty"`
14 | WithMetadata bool `json:"withMetadata,omitempty"`
15 | ExcludeZeroValue bool `json:"excludeZeroValue,omitempty"`
16 | MaxCount string `json:"maxCount,omitempty"`
17 | PageKey string `json:"pageKey,omitempty"`
18 | }
19 |
20 | GetAssetTransfersResult struct {
21 | PageKey string `json:"pageKey"`
22 | Transfers []*AlchemyTransfer `json:"transfers"`
23 | }
24 |
25 | AlchemyRawContract struct {
26 | Value string `json:"value"`
27 | Address string `json:"address"`
28 | Decimal string `json:"decimal"`
29 | }
30 |
31 | AlchemyTransfer struct {
32 | Category string `json:"category"`
33 | BlockNum string `json:"blockNum"`
34 | From string `json:"from"`
35 | To string `json:"to"`
36 | Value float64 `json:"value"`
37 | TokenId string `json:"tokenId"`
38 | Asset string `json:"asset"`
39 | UniqueId string `json:"uniqueId"`
40 | Hash string `json:"hash"`
41 | RawContract *AlchemyRawContract `json:"rawContract"`
42 | }
43 | )
44 |
45 | func (p *Provider) GetAssetTransfers(params GetAssetTransfersParams) (*GetAssetTransfersResult, error) {
46 | ctx, cancel := p.createContext()
47 | defer cancel()
48 |
49 | var result *GetAssetTransfersResult
50 | err := p.rpcClient.CallContext(ctx, &result, "alchemy_getAssetTransfers", params)
51 | if err != nil {
52 | return nil, errors.WithStack(err)
53 | }
54 | return result, nil
55 | }
56 |
--------------------------------------------------------------------------------
/internal/view/util/spinner.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/gdamore/tcell/v2"
7 | "github.com/rivo/tview"
8 | )
9 |
10 | type Spinner struct {
11 | *tview.Box
12 | app *tview.Application
13 |
14 | display bool
15 | counter int
16 | ticker *time.Ticker
17 | }
18 |
19 | var (
20 | frames = []rune(`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`)
21 | )
22 |
23 | func NewSpinner(app *tview.Application) *Spinner {
24 | spinner := &Spinner{
25 | Box: &tview.Box{},
26 | app: app,
27 | display: false,
28 | counter: 0,
29 | }
30 | spinner.SetBorder(false)
31 | spinner.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
32 |
33 | return spinner
34 | }
35 |
36 | func (s *Spinner) StartAndShow() {
37 | s.Start()
38 | s.Display(true)
39 | }
40 |
41 | func (s *Spinner) StopAndHide() {
42 | s.Stop()
43 | s.Display(false)
44 | }
45 |
46 | func (s *Spinner) Start() {
47 | s.ticker = time.NewTicker(100 * time.Millisecond)
48 |
49 | update := func() {
50 | for {
51 | select {
52 | case <-s.ticker.C:
53 | s.pulse()
54 | if s.display {
55 | s.app.Draw()
56 | }
57 | }
58 | }
59 | }
60 | go update()
61 | }
62 |
63 | func (s *Spinner) Stop() {
64 | if s.ticker != nil {
65 | s.ticker.Stop()
66 | }
67 | }
68 |
69 | func (s *Spinner) pulse() {
70 | s.counter++
71 | if s.counter >= len(frames) {
72 | s.counter = s.counter % len(frames)
73 | }
74 | }
75 |
76 | func (s *Spinner) Display(display bool) {
77 | s.display = display
78 | }
79 |
80 | func (s *Spinner) IsDisplay() bool {
81 | return s.display
82 | }
83 |
84 | func (s *Spinner) SetCentral(x, y, width, height int) {
85 | spinnerWidth := 1
86 | spinnerHeight := 1
87 | spinnerX := x + ((width - spinnerWidth) / 2)
88 | spinnerY := y + ((height - spinnerHeight) / 2)
89 | s.Box.SetRect(spinnerX, spinnerY, spinnerWidth, spinnerHeight)
90 | }
91 |
92 | // SetRect implements tview.SetRect
93 | func (s *Spinner) SetRect(x, y, width, height int) {
94 | s.Box.SetRect(x, y, 1, 1)
95 | }
96 |
97 | // Draw implements tview.Draw
98 | func (s *Spinner) Draw(screen tcell.Screen) {
99 | if !s.display {
100 | return
101 | }
102 |
103 | s.Box.DrawForSubclass(screen, s)
104 | x, y, _, _ := s.Box.GetInnerRect()
105 | frame := string(frames[s.counter])
106 | screen.HideCursor()
107 | tview.Print(screen, frame, x, y, 1, tview.AlignLeft, tcell.ColorDefault)
108 | }
109 |
--------------------------------------------------------------------------------
/internal/view/util/loader.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/dyng/ramen/internal/view/style"
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/rivo/tview"
9 | )
10 |
11 | const (
12 | ProgressBarWidth = 3
13 |
14 | ProgressBarHeight = 1
15 |
16 | ProgressBarCell = "▉"
17 | )
18 |
19 | type Loader struct {
20 | *tview.Box
21 | app *tview.Application
22 |
23 | display bool
24 | counter int
25 | cellColor tcell.Color
26 | ticker *time.Ticker
27 | }
28 |
29 | func NewLoader(app *tview.Application) *Loader {
30 | loader := &Loader{
31 | Box: &tview.Box{},
32 | app: app,
33 | display: false,
34 | counter: 0,
35 | cellColor: tcell.ColorDarkOrange,
36 | }
37 | loader.SetBorder(true)
38 | loader.SetTitle(style.Padding("LOADING"))
39 | loader.SetTitleAlign(tview.AlignCenter)
40 | loader.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
41 |
42 | return loader
43 | }
44 |
45 | func (l *Loader) SetCellColor(color tcell.Color) {
46 | l.cellColor = color
47 | }
48 |
49 | func (l *Loader) Start() {
50 | l.ticker = time.NewTicker(500 * time.Millisecond)
51 |
52 | update := func() {
53 | for {
54 | select {
55 | case <-l.ticker.C:
56 | l.pulse()
57 | if l.display {
58 | l.app.Draw()
59 | }
60 | }
61 | }
62 | }
63 | go update()
64 | }
65 |
66 | func (l *Loader) Stop() {
67 | if l.ticker != nil {
68 | l.ticker.Stop()
69 | }
70 | }
71 |
72 | func (l *Loader) pulse() {
73 | l.counter++
74 | }
75 |
76 | func (l *Loader) Display(display bool) {
77 | l.display = display
78 | }
79 |
80 | func (l *Loader) IsDisplay() bool {
81 | return l.display
82 | }
83 |
84 | // SetRect implements tview.SetRect
85 | func (l *Loader) SetCentral(x, y, width, height int) {
86 | loaderWidth := 20
87 | loaderHeight := ProgressBarHeight + 2
88 | loaderX := x + ((width - loaderWidth) / 2)
89 | loaderY := y + ((height - loaderHeight) / 2)
90 | l.Box.SetRect(loaderX, loaderY, loaderWidth, loaderHeight)
91 | }
92 |
93 | // Draw implements tview.Draw
94 | func (l *Loader) Draw(screen tcell.Screen) {
95 | if !l.display {
96 | return
97 | }
98 |
99 | l.Box.DrawForSubclass(screen, l)
100 | x, y, width, height := l.Box.GetInnerRect()
101 |
102 | if l.counter >= width-ProgressBarWidth {
103 | l.counter = l.counter % (width - ProgressBarWidth)
104 | }
105 | for i := 0; i < height; i++ {
106 | for j := 0; j < ProgressBarWidth; j++ {
107 | tview.Print(screen, ProgressBarCell, x+l.counter+j, y+i, width, tview.AlignLeft, l.cellColor)
108 | }
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/internal/view/helper.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/dyng/ramen/internal/common"
7 | serv "github.com/dyng/ramen/internal/service"
8 | "github.com/dyng/ramen/internal/view/util"
9 | "github.com/gdamore/tcell/v2"
10 | "github.com/rivo/tview"
11 | )
12 |
13 | type KeymapPrimitive interface {
14 | SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *tview.Box
15 |
16 | KeyMaps() util.KeyMaps
17 | }
18 |
19 | func InitKeymap(p KeymapPrimitive, app *App) {
20 | keymaps := p.KeyMaps()
21 | p.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
22 | // do not capture characters for InputField and TextArea
23 | switch app.GetFocus().(type) {
24 | case *tview.InputField, *tview.TextArea:
25 | if !IsControlKey(event) {
26 | return event
27 | }
28 | }
29 |
30 | handler, ok := keymaps.FindHandler(util.AsKey(event))
31 | if ok {
32 | handler(event)
33 | return nil
34 | } else {
35 | return event
36 | }
37 | })
38 | }
39 |
40 | // IsControlKey returns true if the key is a control key.
41 | func IsControlKey(evt *tcell.EventKey) bool {
42 | if evt.Modifiers() & tcell.ModCtrl != 0 {
43 | return true
44 | }
45 | switch evt.Key() {
46 | case tcell.KeyEsc, tcell.KeyTab:
47 | return true
48 | }
49 | return false
50 | }
51 |
52 | func Inc(i *int) int {
53 | t := *i
54 | *i++
55 | return t
56 | }
57 |
58 | func StyledAccountType(t serv.AccountType) string {
59 | switch t {
60 | case serv.TypeWallet:
61 | return fmt.Sprintf("[::b]%s[-:-:-]", t)
62 | case serv.TypeContract:
63 | return fmt.Sprintf("[dodgerblue::b]%s[-:-:-]", t)
64 | default:
65 | return t.String()
66 | }
67 | }
68 |
69 | func StyledNetworkName(n serv.Network) string {
70 | netType := n.NetType()
71 |
72 | if netType == serv.TypeMainnet {
73 | return "[crimson::b]Mainnet[-:-:-]"
74 | }
75 |
76 | if netType == serv.TypeTestnet {
77 | return fmt.Sprintf("[lightgreen::b]%s[-:-:-]", n.Name)
78 | }
79 |
80 | chainId := n.ChainId.String()
81 |
82 | if chainId == "1337" {
83 | return "[lightgreen::b]Ganache[-:-:-]"
84 | }
85 |
86 | if chainId == "31337" {
87 | return "[lightgreen::b]Hardhat[-:-:-]"
88 | }
89 |
90 | return n.Name
91 | }
92 |
93 | func StyledTxnDirection(base *common.Address, txn common.Transaction) string {
94 | if base == nil {
95 | return ""
96 | }
97 |
98 | if txn.From().String() == base.String() {
99 | return "[sandybrown]OUT[-]"
100 | }
101 |
102 | if txn.To() != nil && txn.To().String() == base.String() {
103 | return "[lightgreen]IN[-]"
104 | }
105 |
106 | return ""
107 | }
108 |
--------------------------------------------------------------------------------
/internal/service/account.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "math/big"
5 | "sync/atomic"
6 |
7 | "github.com/dyng/ramen/internal/common"
8 | "github.com/ethereum/go-ethereum/log"
9 | )
10 |
11 | const (
12 | // TypeWallet is an EOA account.
13 | TypeWallet AccountType = "Wallet"
14 | // TypeContract is a SmartContract account.
15 | TypeContract = "Contract"
16 | )
17 |
18 | // AccountType represents two types of Etheruem's account: EOA and SmartContract.
19 | type AccountType string
20 |
21 | func (at AccountType) String() string {
22 | return string(at)
23 | }
24 |
25 | // Account represents an account of Etheruem network.
26 | type Account struct {
27 | service *Service
28 | address common.Address
29 | balance atomic.Pointer[big.Int]
30 | code []byte // byte code of this account, nil if account is an EOA.
31 | }
32 |
33 | // GetAddress returns address of this account.
34 | func (a *Account) GetAddress() common.Address {
35 | return a.address
36 | }
37 |
38 | // GetType returns type of this account, either Wallet or Contract.
39 | func (a *Account) GetType() AccountType {
40 | if len(a.code) == 0 {
41 | return TypeWallet
42 | } else {
43 | return TypeContract
44 | }
45 | }
46 |
47 | // IsContract returns true if this account is a smart contract.
48 | func (a *Account) IsContract() bool {
49 | return a.GetType() == TypeContract
50 | }
51 |
52 | // AsContract upgrade this account object to a contract.
53 | func (a *Account) AsContract() (*Contract, error) {
54 | return a.service.ToContract(a)
55 | }
56 |
57 | // GetBalance returns cached balance of this account.
58 | func (a *Account) GetBalance() common.BigInt {
59 | // FIXME: race condition
60 | if a.balance.Load() == nil {
61 | bal, err := a.GetBalanceForce()
62 | if err != nil {
63 | log.Error("Failed to fetch balance", "address", a.address, "error", err)
64 | }
65 | return bal
66 | } else {
67 | return a.balance.Load()
68 | }
69 | }
70 |
71 | // GetBalanceForce will query for current account's balance, store it in cache and return.
72 | func (a *Account) GetBalanceForce() (common.BigInt, error) {
73 | bal, err := a.service.provider.GetBalance(a.address)
74 | if err == nil {
75 | a.balance.Swap(bal)
76 | } else {
77 | bal = big.NewInt(0) // use 0 as fallback value
78 | }
79 | return bal, err
80 | }
81 |
82 | // UpdateBalance will update cache of current account's balance
83 | func (a *Account) UpdateBalance() bool {
84 | _, err := a.GetBalanceForce()
85 | return err == nil
86 | }
87 |
88 | // ClearCache will clear cached balance
89 | func (a *Account) ClearCache() {
90 | a.balance.Store(nil)
91 | }
92 |
93 | // GetTransactions returns transactions of this account.
94 | func (a *Account) GetTransactions() (common.Transactions, error) {
95 | return a.service.GetTransactionHistory(a.address)
96 | }
97 |
--------------------------------------------------------------------------------
/internal/service/contract.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/dyng/ramen/internal/common"
7 | "github.com/ethereum/go-ethereum/accounts/abi"
8 | "github.com/ethereum/go-ethereum/log"
9 | "github.com/pkg/errors"
10 | )
11 |
12 | // Contract represents a smart contract deployed on Ethereum network.
13 | type Contract struct {
14 | *Account
15 | abi *abi.ABI
16 | source string
17 | }
18 |
19 | // HasABI returns true if this contract has a known ABI.
20 | func (c *Contract) HasABI() bool {
21 | return c.abi != nil
22 | }
23 |
24 | // GetABI returns ABI of this contract, may be nil if ABI is unknown.
25 | func (c *Contract) GetABI() *abi.ABI {
26 | return c.abi
27 | }
28 |
29 | // GetSource returns source of this contract, may be empty if source cannot be retrieved.
30 | func (c *Contract) GetSource() string {
31 | return c.source
32 | }
33 |
34 | // ImportABI generates ABI from a json representation of ABI.
35 | func (c *Contract) ImportABI(abiJson string) error {
36 | log.Debug("Try to parse abi json", "json", abiJson)
37 | parsedAbi, err := abi.JSON(strings.NewReader(abiJson))
38 | if err != nil {
39 | return errors.WithStack(err)
40 | }
41 |
42 | c.abi = &parsedAbi
43 |
44 | return nil
45 | }
46 |
47 | // ParseCalldata parses calldata into method name and arguments.
48 | func (c *Contract) ParseCalldata(data []byte) (*abi.Method, []any, error) {
49 | if c.abi == nil {
50 | return nil, nil, errors.New("ABI is not loaded")
51 | }
52 |
53 | m, err := c.abi.MethodById(data[:4])
54 | if err != nil {
55 | return nil, nil, errors.WithStack(err)
56 | }
57 |
58 | args, err := m.Inputs.Unpack(data[4:])
59 | if err != nil {
60 | return nil, nil, errors.WithStack(err)
61 | }
62 |
63 | return m, args, nil
64 | }
65 |
66 | // Call invokes a constant method of this contract. The arguments should be unpacked into correct type.
67 | func (c *Contract) Call(method string, args ...any) ([]any, error) {
68 | m, ok := c.abi.Methods[method]
69 | if !ok {
70 | return nil, errors.Errorf("Method %s is not found in contract", method)
71 | }
72 |
73 | if !m.IsConstant() {
74 | return nil, errors.Errorf("Method %s is not a constant method", method)
75 | }
76 |
77 | log.Debug("Try to call contract", "method", method, "args", args)
78 | return c.service.provider.CallContract(c.address, c.abi, method, args...)
79 | }
80 |
81 | // Send invokes a non-constant method of this contract. This method will sign and send the transaction to the network.
82 | func (c *Contract) Send(signer *Signer, method string, args ...any) (common.Hash, error) {
83 | _, ok := c.abi.Methods[method]
84 | if !ok {
85 | return common.Hash{}, errors.Errorf("Method %s is not found in contract", method)
86 | }
87 |
88 | return signer.CallContract(c.GetAddress(), c.abi, method, args...)
89 | }
90 |
--------------------------------------------------------------------------------
/internal/view/signer.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/common"
5 | "github.com/dyng/ramen/internal/common/conv"
6 | "github.com/dyng/ramen/internal/service"
7 | "github.com/dyng/ramen/internal/view/style"
8 | "github.com/dyng/ramen/internal/view/util"
9 | "github.com/rivo/tview"
10 | )
11 |
12 | type Signer struct {
13 | tview.Primitive
14 | app *App
15 |
16 | signer *service.Signer
17 | initialized bool
18 | avatar *util.Avatar
19 | table *tview.Table
20 | address *util.Section
21 | balance *util.Section
22 | }
23 |
24 | func NewSigner(app *App) *Signer {
25 | signer := &Signer{
26 | app: app,
27 | avatar: util.NewAvatar(style.AvatarSize),
28 | table: tview.NewTable(),
29 | }
30 |
31 | // setup layout
32 | signer.initLayout()
33 |
34 | // subscribe tick event
35 | app.eventBus.Subscribe(service.TopicNewBlock, signer.onNewBlock)
36 |
37 | return signer
38 | }
39 |
40 | func (si *Signer) initLayout() {
41 | // not signed in by default
42 | si.layoutNoSigner()
43 | }
44 |
45 | func (si *Signer) HasSignedIn() bool {
46 | return si.signer != nil
47 | }
48 |
49 | func (si *Signer) GetSigner() *service.Signer {
50 | return si.signer
51 | }
52 |
53 | func (si *Signer) SetSigner(signer *service.Signer) {
54 | si.signer = signer
55 | si.refresh()
56 | }
57 |
58 | func (si *Signer) refresh() {
59 | if !si.initialized {
60 | si.layoutSomeSigner()
61 | si.initialized = true
62 | }
63 |
64 | current := si.signer
65 | addr := current.GetAddress()
66 |
67 | // update avatar
68 | si.avatar.SetAddress(addr)
69 |
70 | // update address
71 | si.address.SetText(addr.Hex())
72 |
73 | // update balance
74 | bal := current.GetBalance()
75 | si.balance.SetText(conv.ToEther(bal).String())
76 | }
77 |
78 | func (si *Signer) layoutNoSigner() {
79 | cell := tview.NewTableCell("[dimgrey]Not Signed In[-]")
80 | cell.SetAlign(tview.AlignLeft)
81 | cell.SetExpansion(1)
82 | si.table.SetCell(0, 0, cell)
83 | si.Primitive = si.table
84 | }
85 |
86 | func (si *Signer) layoutSomeSigner() {
87 | s := si.app.config.Style()
88 |
89 | flex := tview.NewFlex()
90 | flex.SetDirection(tview.FlexColumn)
91 | flex.AddItem(si.avatar, style.AvatarSize*2+1, 0, false)
92 | flex.AddItem(si.table, 0, 1, false)
93 |
94 | address := util.NewSectionWithColor("Address:", s.SectionColor2, util.NAValue, s.FgColor)
95 | address.AddToTable(si.table, 0, 0)
96 | si.address = address
97 |
98 | balance := util.NewSectionWithColor("Balance:", s.SectionColor2, util.NAValue, s.FgColor)
99 | balance.AddToTable(si.table, 1, 0)
100 | si.balance = balance
101 |
102 | si.Primitive = flex
103 | }
104 |
105 | func (si *Signer) onNewBlock(block *common.Block) {
106 | if si.signer != nil {
107 | si.signer.UpdateBalance()
108 | si.refresh()
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/internal/view/txn_preview.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/view/util"
5 | "github.com/gdamore/tcell/v2"
6 | "github.com/rivo/tview"
7 | )
8 |
9 | const (
10 | // txnPreviewDialogMinHeight is the minimum height of the transaction preview dialog.
11 | txnPreviewDialogMinHeight = 20
12 | // txnPreviewDialogMinWidth is the minimum width of the transaction preview dialog.
13 | txnPreviewDialogMinWidth = 50
14 | )
15 |
16 | type TxnPreviewDialog struct {
17 | *TransactionDetail
18 | app *App
19 | display bool
20 | lastFocus tview.Primitive
21 | }
22 |
23 | func NewTxnPreviewDialog(app *App) *TxnPreviewDialog {
24 | d := &TxnPreviewDialog{
25 | TransactionDetail: NewTransactionDetail(app),
26 | app: app,
27 | display: false,
28 | }
29 |
30 | // setup keymap
31 | d.initKeymap()
32 |
33 | return d
34 | }
35 |
36 | func (d *TxnPreviewDialog) Show() {
37 | if !d.display {
38 | // save last focused element
39 | d.lastFocus = d.app.GetFocus()
40 |
41 | d.Display(true)
42 | d.app.SetFocus(d)
43 | }
44 | }
45 |
46 | func (d *TxnPreviewDialog) Hide() {
47 | if d.display {
48 | d.Display(false)
49 | d.app.SetFocus(d.lastFocus)
50 | }
51 | }
52 |
53 | func (d *TxnPreviewDialog) initKeymap() {
54 | InitKeymap(d, d.app)
55 | }
56 |
57 | // KeyMaps implements KeymapPrimitive
58 | func (d *TxnPreviewDialog) KeyMaps() util.KeyMaps {
59 | keymaps := make(util.KeyMaps, 0)
60 | keymaps = append(keymaps, util.NewSimpleKey(tcell.KeyEsc, d.Hide))
61 | keymaps = append(keymaps, util.NewSimpleKey(tcell.KeyEnter, d.Hide))
62 | keymaps = append(keymaps, util.NewSimpleKey(util.KeySpace, d.Hide))
63 | keymaps = append(keymaps, util.NewSimpleKey(util.KeyF, func() {
64 | d.Hide()
65 | d.ViewSender()
66 | }))
67 | keymaps = append(keymaps, util.NewSimpleKey(util.KeyT, func() {
68 | d.Hide()
69 | d.ViewReceiver()
70 | }))
71 | return keymaps
72 | }
73 |
74 | func (d *TxnPreviewDialog) Display(display bool) {
75 | d.display = display
76 | }
77 |
78 | func (d *TxnPreviewDialog) IsDisplay() bool {
79 | return d.display
80 | }
81 |
82 | // Draw implements tview.Primitive
83 | func (d *TxnPreviewDialog) Draw(screen tcell.Screen) {
84 | if d.display {
85 | d.TransactionDetail.Draw(screen)
86 | }
87 | }
88 |
89 | func (d *TxnPreviewDialog) SetCentral(x int, y int, width int, height int) {
90 | dialogWidth := width - width/3
91 | dialogHeight := height / 2
92 | if dialogWidth < txnPreviewDialogMinWidth {
93 | dialogWidth = txnPreviewDialogMinWidth
94 | }
95 | if dialogHeight < notificationMinHeight {
96 | dialogHeight = notificationMinHeight
97 | }
98 | dialogX := x + ((width - dialogWidth) / 2)
99 | dialogY := y + ((height - dialogHeight) / 2)
100 | d.TransactionDetail.SetRect(dialogX, dialogY, dialogWidth, dialogHeight)
101 | }
102 |
--------------------------------------------------------------------------------
/internal/view/notification.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/view/style"
5 | "github.com/dyng/ramen/internal/view/util"
6 | "github.com/gdamore/tcell/v2"
7 | "github.com/rivo/tview"
8 | )
9 |
10 | const (
11 | // notificationMinHeight is the minimum height of the notification.
12 | notificationMinHeight = 10
13 | )
14 |
15 | type Notification struct {
16 | *tview.TextView
17 | app *App
18 | display bool
19 |
20 | title string
21 | text string
22 | lastFocus tview.Primitive
23 | }
24 |
25 | func NewNotification(app *App) *Notification {
26 | n := &Notification{
27 | app: app,
28 | display: false,
29 | }
30 |
31 | // setup layout
32 | n.initLayout()
33 |
34 | // setup keymap
35 | n.initKeymap()
36 |
37 | return n
38 | }
39 |
40 | func (n *Notification) SetContent(title string, text string) {
41 | n.title = title
42 | n.text = text
43 | n.refresh()
44 | }
45 |
46 | func (n *Notification) Show() {
47 | if !n.display {
48 | // save last focused element
49 | n.lastFocus = n.app.GetFocus()
50 |
51 | n.Display(true)
52 | n.app.SetFocus(n)
53 | }
54 | }
55 |
56 | func (n *Notification) Hide() {
57 | if n.display {
58 | n.Display(false)
59 | n.app.SetFocus(n.lastFocus)
60 | }
61 | }
62 |
63 | func (n *Notification) initLayout() {
64 | s := n.app.config.Style()
65 |
66 | tv := tview.NewTextView()
67 | tv.SetBorder(true)
68 | tv.SetBorderColor(s.BorderColor2)
69 | tv.SetWrap(true)
70 | n.TextView = tv
71 | }
72 |
73 | func (n *Notification) initKeymap() {
74 | InitKeymap(n, n.app)
75 | }
76 |
77 | // KeyMaps implements KeymapPrimitive
78 | func (n *Notification) KeyMaps() util.KeyMaps {
79 | keymaps := make(util.KeyMaps, 0)
80 | keymaps = append(keymaps, util.NewSimpleKey(tcell.KeyEsc, n.Hide))
81 | keymaps = append(keymaps, util.NewSimpleKey(tcell.KeyEnter, n.Hide))
82 | keymaps = append(keymaps, util.NewSimpleKey(util.KeySpace, n.Hide))
83 | return keymaps
84 | }
85 |
86 | func (n *Notification) refresh() {
87 | n.SetTitle(style.BoldPadding(n.title))
88 | n.SetText(n.text)
89 | }
90 |
91 | func (n *Notification) Display(display bool) {
92 | n.display = display
93 | }
94 |
95 | func (n *Notification) IsDisplay() bool {
96 | return n.display
97 | }
98 |
99 | // Draw implements tview.Primitive
100 | func (n *Notification) Draw(screen tcell.Screen) {
101 | if n.display {
102 | n.TextView.Draw(screen)
103 | }
104 | }
105 |
106 | func (n *Notification) SetCentral(x int, y int, width int, height int) {
107 | dialogWidth := width - width/3
108 | dialogHeight := height / 4
109 | if dialogHeight < notificationMinHeight {
110 | dialogHeight = notificationMinHeight
111 | }
112 | dialogX := x + ((width - dialogWidth) / 2)
113 | dialogY := y + ((height - dialogHeight) / 2)
114 | n.TextView.SetRect(dialogX, dialogY, dialogWidth, dialogHeight)
115 | }
116 |
--------------------------------------------------------------------------------
/internal/view/chain_info.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/dyng/ramen/internal/common"
7 | "github.com/dyng/ramen/internal/common/conv"
8 | "github.com/dyng/ramen/internal/service"
9 | "github.com/dyng/ramen/internal/view/util"
10 | "github.com/rivo/tview"
11 | "github.com/shopspring/decimal"
12 | )
13 |
14 | type ChainInfo struct {
15 | *tview.Table
16 | app *App
17 |
18 | network *util.Section
19 | height *util.Section
20 | gasPrice *util.Section
21 | ethPrice *util.Section
22 | prevPrice *decimal.Decimal
23 | }
24 |
25 | func NewChainInfo(app *App) *ChainInfo {
26 | chainInfo := &ChainInfo{
27 | Table: tview.NewTable(),
28 | app: app,
29 | }
30 |
31 | // setup layout
32 | chainInfo.initLayout()
33 |
34 | // subscribe for new data
35 | chainInfo.app.eventBus.Subscribe(service.TopicNewBlock, chainInfo.onNewBlock)
36 | chainInfo.app.eventBus.Subscribe(service.TopicChainData, chainInfo.onNewChainData)
37 |
38 | return chainInfo
39 | }
40 |
41 | func (ci *ChainInfo) initLayout() {
42 | s := ci.app.config.Style()
43 |
44 | network := util.NewSectionWithStyle("Network:", util.NAValue, s)
45 | network.AddToTable(ci.Table, 0, 0)
46 | ci.network = network
47 |
48 | height := util.NewSectionWithStyle("Block Height:", util.NAValue, s)
49 | height.AddToTable(ci.Table, 1, 0)
50 | ci.height = height
51 |
52 | gasPrice := util.NewSectionWithStyle("Gas Price:", util.NAValue, s)
53 | gasPrice.AddToTable(ci.Table, 2, 0)
54 | ci.gasPrice = gasPrice
55 |
56 | ethPrice := util.NewSectionWithStyle("Ether:", util.NAValue, s)
57 | ethPrice.AddToTable(ci.Table, 0, 2)
58 | ci.ethPrice = ethPrice
59 | }
60 |
61 | func (ci *ChainInfo) SetNetwork(network string) {
62 | ci.network.SetText(network)
63 | }
64 |
65 | func (ci *ChainInfo) SetHeight(height uint64) {
66 | ci.height.SetText(fmt.Sprint(height))
67 | }
68 |
69 | func (ci *ChainInfo) SetGasPrice(gasPrice common.BigInt) {
70 | ci.gasPrice.SetText(fmt.Sprintf("%s Gwei", conv.ToGwei(gasPrice)))
71 | }
72 |
73 | func (ci *ChainInfo) SetEthPrice(price decimal.Decimal) {
74 | if ci.prevPrice == nil {
75 | ci.ethPrice.SetText(fmt.Sprintf("$%s", price))
76 | } else {
77 | c := ci.prevPrice.Cmp(price)
78 | if c == 0 {
79 | // if price does not change, don't change anything
80 | return
81 | }
82 |
83 | if c < 0 {
84 | ci.ethPrice.SetText(fmt.Sprintf("[lightgreen]$%s ▲[-]", price))
85 | } else {
86 | ci.ethPrice.SetText(fmt.Sprintf("[crimson]$%s ▼[-]", price))
87 | }
88 | }
89 |
90 | ci.prevPrice = &price
91 | }
92 |
93 | func (ci *ChainInfo) onNewBlock(block *common.Block) {
94 | ci.app.QueueUpdateDraw(func() {
95 | ci.SetHeight(block.Number().Uint64())
96 | })
97 | }
98 |
99 | func (ci *ChainInfo) onNewChainData(data *service.ChainData) {
100 | ci.app.QueueUpdateDraw(func() {
101 | if data.Price != nil {
102 | ci.SetEthPrice(*data.Price)
103 | }
104 | if data.GasPrice != nil {
105 | ci.SetGasPrice(data.GasPrice)
106 | }
107 | })
108 | }
109 |
--------------------------------------------------------------------------------
/internal/view/app.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/asaskevich/EventBus"
5 | "github.com/dyng/ramen/internal/common"
6 | conf "github.com/dyng/ramen/internal/config"
7 | serv "github.com/dyng/ramen/internal/service"
8 | "github.com/ethereum/go-ethereum/log"
9 | "github.com/rivo/tview"
10 | )
11 |
12 | type App struct {
13 | *tview.Application
14 | root *Root
15 |
16 | service *serv.Service
17 | config *conf.Config
18 | eventBus EventBus.Bus
19 | syncer *serv.Syncer
20 | }
21 |
22 | func NewApp(config *conf.Config) *App {
23 | log.Info("Start application with configurations", "config", config)
24 |
25 | app := &App{
26 | Application: tview.NewApplication(),
27 | config: config,
28 | eventBus: EventBus.New(),
29 | service: serv.NewService(config),
30 | }
31 |
32 | // syncer
33 | syncer := serv.NewSyncer(app.service, app.eventBus)
34 | app.syncer = syncer
35 |
36 | // root
37 | root := NewRoot(app)
38 | app.root = root
39 | app.SetRoot(root, true)
40 |
41 | return app
42 | }
43 |
44 | func (a *App) Start() error {
45 | // first synchronization at startup
46 | err := a.firstSync()
47 | if err != nil {
48 | log.Error("Failed to synchronize chain info", "error", err)
49 | common.Exit("Failed to synchronize chain info: %v", err)
50 | }
51 |
52 | // show homepage
53 | a.root.ShowHomePage()
54 |
55 | // start application
56 | log.Info("Application is running")
57 | return a.Run()
58 | }
59 |
60 | // firstSync synchronize latest blockchain informations and populate data to widgets
61 | func (a *App) firstSync() error {
62 | // update network
63 | network := a.service.GetNetwork()
64 | a.root.chainInfo.SetNetwork(StyledNetworkName(network))
65 |
66 | // update block height
67 | go func() {
68 | height, err := a.service.GetBlockHeight()
69 | if err != nil {
70 | log.Error("Failed to fetch block height", "error", err)
71 | }
72 |
73 | price, err := a.service.GetEthPrice()
74 | if err != nil {
75 | log.Error("Failed to fetch ether's price", "error", err)
76 | }
77 |
78 | gasPrice, err := a.service.GetGasPrice()
79 | if err != nil {
80 | log.Error("Failed to fetch gas price", "error", err)
81 | }
82 |
83 | a.QueueUpdateDraw(func() {
84 | a.root.chainInfo.SetHeight(height)
85 | if price != nil {
86 | a.root.chainInfo.SetEthPrice(*price)
87 | }
88 | if gasPrice != nil {
89 | a.root.chainInfo.SetGasPrice(gasPrice)
90 | }
91 | })
92 | }()
93 |
94 | // load newest transactions
95 | a.root.home.transactionList.LoadAsync(func() (common.Transactions, error) {
96 | netType := a.service.GetNetwork().NetType()
97 | if netType == serv.TypeDevnet {
98 | return a.service.GetLatestTransactions(100, 5)
99 | } else {
100 | return a.service.GetLatestTransactions(100, 1)
101 | }
102 | })
103 |
104 | // start syncer
105 | if err := a.syncer.Start(); err != nil {
106 | log.Error("Failed to start syncer", "error", err)
107 | return err
108 | }
109 |
110 | return nil
111 | }
112 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/dyng/ramen/internal/common"
7 | conf "github.com/dyng/ramen/internal/config"
8 | "github.com/dyng/ramen/internal/view"
9 | "github.com/ethereum/go-ethereum/log"
10 | "github.com/pkg/errors"
11 | "github.com/spf13/cobra"
12 | )
13 |
14 | const (
15 | appName = "ramen"
16 | appDesc = "A graphic CLI for interaction with Ethereum easily and happily, by builders, for builders.🍜"
17 | )
18 |
19 | var (
20 | config = conf.NewConfig()
21 | rootCmd = NewRootCmd()
22 | )
23 |
24 | func init() {
25 | rootCmd.AddCommand(versionCmd())
26 | }
27 |
28 | func Execute() {
29 | if err := rootCmd.Execute(); err != nil {
30 | common.Exit("Root command failed: %v", err)
31 | }
32 | }
33 |
34 | func NewRootCmd() *cobra.Command {
35 | cmd := cobra.Command{
36 | Use: appName,
37 | Short: appDesc,
38 | Long: appDesc,
39 | Run: run,
40 | }
41 |
42 | flags := cmd.Flags()
43 |
44 | flags.BoolVar(
45 | &config.DebugMode,
46 | "debug",
47 | false,
48 | "Should ramen run in debug mode",
49 | )
50 | flags.StringVarP(
51 | &config.ConfigFile,
52 | "config-file",
53 | "c",
54 | conf.DefaultConfigFile,
55 | "Path to ramen's config file",
56 | )
57 | flags.StringVarP(
58 | &config.Network,
59 | "network",
60 | "n",
61 | conf.DefaultNetwork,
62 | "Specify the chain that ramen will connect to",
63 | )
64 | flags.StringVarP(
65 | &config.Provider,
66 | "provider",
67 | "p",
68 | conf.DefaultProvider,
69 | "Specify a blockchain provider",
70 | )
71 | flags.StringVar(
72 | &config.ApiKey,
73 | "apikey",
74 | "",
75 | "ApiKey for specified Ethereum JSON-RPC provider",
76 | )
77 | flags.StringVar(
78 | &config.EtherscanApiKey,
79 | "etherscan-apikey",
80 | "",
81 | "ApiKey for Etherscan API",
82 | )
83 |
84 | return &cmd
85 | }
86 |
87 | func run(cmd *cobra.Command, args []string) {
88 | // recovery
89 | defer logPanicAndExit()
90 |
91 | // setup logger
92 | initLogger()
93 |
94 | // read and parse configurations from config file
95 | err := conf.ParseConfig(config)
96 | if err != nil {
97 | common.Exit("Cannot parse config file: %v", err)
98 | }
99 |
100 | // validate config
101 | err = config.Validate()
102 | if err != nil {
103 | common.Exit("Invalid config: %v", err)
104 | }
105 |
106 | // start application
107 | view.NewApp(config).Start()
108 | }
109 |
110 | func initLogger() {
111 | // FIXME: use log file in config
112 | path := "/tmp/ramen.log"
113 | file, err := os.OpenFile(path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0666)
114 | if err != nil {
115 | common.Exit("Cannot create log file at path %s: %v", path, err)
116 | }
117 |
118 | handler := common.ErrorStackHandler(log.StreamHandler(file, log.TerminalFormat(false)))
119 | if config.DebugMode {
120 | handler = log.LvlFilterHandler(log.LvlDebug, handler)
121 | } else {
122 | handler = log.LvlFilterHandler(log.LvlInfo, handler)
123 | }
124 | log.Root().SetHandler(handler)
125 | }
126 |
127 | func logPanicAndExit() {
128 | if r := recover(); r != nil {
129 | log.Error("Unexpected error occurs", "error", errors.Errorf("%v", r))
130 | common.Exit("Exit due to unexpected error: %v", r)
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/internal/service/syncer.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "errors"
5 | "math/big"
6 | "sync"
7 | "time"
8 |
9 | "github.com/asaskevich/EventBus"
10 | "github.com/dyng/ramen/internal/common"
11 | "github.com/ethereum/go-ethereum"
12 | "github.com/ethereum/go-ethereum/log"
13 | "github.com/shopspring/decimal"
14 | )
15 |
16 | const (
17 | // TopicNewBlock is the topic about received new blocks
18 | TopicNewBlock = "service:newBlock"
19 | // TopicChainData is the topic about latest chain data (ether price, gas price, etc.)
20 | TopicChainData = "service:chainData"
21 | // TopicTick is a topic that receives tick event periodically
22 | TopicTick = "service:tick"
23 |
24 | // UpdatePeriod is the time duration between two updates
25 | UpdatePeriod = 10 * time.Second
26 | )
27 |
28 | type ChainData struct {
29 | Price *decimal.Decimal
30 | GasPrice *big.Int
31 | }
32 |
33 | // Syncer is used to synchronize information from blockchain.
34 | type Syncer struct {
35 | *sync.Mutex
36 |
37 | started bool
38 | service *Service
39 | eventBus EventBus.Bus
40 | ticker *time.Ticker
41 | chBlock chan *common.Header
42 | ethSub ethereum.Subscription
43 | }
44 |
45 | func NewSyncer(service *Service, eventBus EventBus.Bus) *Syncer {
46 | return &Syncer{
47 | Mutex: &sync.Mutex{},
48 | started: false,
49 | service: service,
50 | eventBus: eventBus,
51 | chBlock: make(chan *common.Header),
52 | }
53 | }
54 |
55 | func (s *Syncer) Start() error {
56 | s.Lock()
57 | defer s.Unlock()
58 |
59 | if s.started {
60 | return errors.New("syncer is already started")
61 | }
62 | s.started = true
63 |
64 | // subscribe to new blocks
65 | sub, err := s.service.GetProvider().SubscribeNewHead(s.chBlock)
66 | if err != nil {
67 | return err
68 | }
69 | s.ethSub = sub
70 |
71 | // start ticker for periodic update
72 | s.ticker = time.NewTicker(UpdatePeriod)
73 |
74 | // start syncing
75 | go s.sync()
76 |
77 | return nil
78 | }
79 |
80 | func (s *Syncer) sync() {
81 | for {
82 | select {
83 | case err := <-s.ethSub.Err():
84 | log.Error("Subscription channel failed", "error", err)
85 | case newHeader := <-s.chBlock:
86 | log.Info("Received new block header", "hash", newHeader.Hash(),
87 | "number", newHeader.Number)
88 |
89 | block, err := s.service.GetProvider().GetBlockByHash(newHeader.Hash())
90 | if err != nil {
91 | log.Error("Failed to fetch block by hash", "hash", newHeader.Hash(), "error", err)
92 | continue
93 | }
94 |
95 | s.eventBus.Publish(TopicNewBlock, block)
96 | case tick := <-s.ticker.C:
97 | log.Debug("Process periodic synchronization", "tick", tick)
98 |
99 | // update eth price
100 | price, err := s.service.GetEthPrice()
101 | if err != nil {
102 | log.Error("Failed to fetch ether's price", "error", err)
103 | }
104 |
105 | // update gas price
106 | gasPrice, err := s.service.GetGasPrice()
107 | if err != nil {
108 | log.Error("Failed to fetch gas price", "error", err)
109 | }
110 |
111 | data := &ChainData{
112 | Price: price,
113 | GasPrice: gasPrice,
114 | }
115 | s.eventBus.Publish(TopicChainData, data)
116 |
117 | go func() {
118 | s.eventBus.Publish(TopicTick, tick)
119 | }()
120 | }
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/internal/provider/etherum_test.go:
--------------------------------------------------------------------------------
1 | package provider
2 |
3 | import (
4 | "math/big"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/dyng/ramen/internal/common"
9 | "github.com/ethereum/go-ethereum/accounts/abi"
10 | gcommon "github.com/ethereum/go-ethereum/common"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | const (
15 | testAlchemyEndpoint = "wss://eth-mainnet.g.alchemy.com/v2/1DYmd-KT-4evVd_-O56p5HTgk2t5cuVu"
16 |
17 | usdtContractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
18 | usdtFunctionABI = "[{\"constant\":true,\"inputs\":[{\"name\":\"who\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\"}]"
19 | )
20 |
21 | func TestBatchTransactionByHash_NoError(t *testing.T) {
22 | // prepare
23 | provider := NewProvider(testAlchemyEndpoint, ProviderAlchemy)
24 |
25 | // process
26 | hashList := []common.Hash{
27 | gcommon.HexToHash("0xc2a5c78171f96e1268035ee8c90436dc6945a73b03a4970a6c38f1635a6a1bd2"),
28 | gcommon.HexToHash("0x5e9a9c54899325819a04e591d1930bee53b8798de3d26f438e9beba89de2fafa"),
29 | gcommon.HexToHash("0xf53efe987616ecf1b1178de1dc8ae58946f119003774abb132c3cd9c3fccb762"),
30 | }
31 | txns, err := provider.BatchTransactionByHash(hashList)
32 |
33 | // verify
34 | assert.NoError(t, err)
35 | assert.Len(t, txns, 3)
36 | for _, txn := range txns {
37 | assert.NotNil(t, txn.BlockNumber(), "block number should not be nil")
38 | assert.NotNil(t, txn.Hash(), "hash should not be nil")
39 | assert.NotNil(t, txn.From(), "sender should not be nil")
40 | assert.NotNil(t, txn.To(), "receiver should not be nil")
41 | }
42 | }
43 |
44 | func TestBatchBlockByNumber_NoError(t *testing.T) {
45 | // prepare
46 | provider := NewProvider(testAlchemyEndpoint, ProviderAlchemy)
47 |
48 | // process
49 | numberList := []common.BigInt{
50 | big.NewInt(16748002),
51 | big.NewInt(16748001),
52 | big.NewInt(16748000),
53 | }
54 | blocks, err := provider.BatchBlockByNumber(numberList)
55 |
56 | // verify
57 | assert.NoError(t, err)
58 | assert.Len(t, blocks, 3)
59 | for _, block := range blocks {
60 | assert.NotNil(t, block.Number(), "block number should not be nil")
61 | assert.NotNil(t, block.Hash(), "hash should not be nil")
62 | assert.NotEmpty(t, block.Transactions(), "transactions should not be empty")
63 | }
64 | }
65 |
66 | func TestCallContract_NoError(t *testing.T) {
67 | // prepare
68 | provider := NewProvider(testAlchemyEndpoint, ProviderAlchemy)
69 | usdtABI, _ := abi.JSON(strings.NewReader(usdtFunctionABI))
70 | usdtAddr := gcommon.HexToAddress(usdtContractAddress)
71 | argAddr := gcommon.HexToAddress("0x759B7e31E6411AB92CF382b3d4733D98134052a7")
72 |
73 | // process
74 | result, err := provider.CallContract(usdtAddr, &usdtABI, "balanceOf", argAddr)
75 |
76 | // verify
77 | assert.NoError(t, err)
78 | balance := result[0].(common.BigInt)
79 | assert.Equal(t, 1, balance.Cmp(big.NewInt(100)), "balance should be greater than 100")
80 | }
81 |
82 | func TestGetGasPrice_NoError(t *testing.T) {
83 | // prepare
84 | provider := NewProvider(testAlchemyEndpoint, ProviderAlchemy)
85 |
86 | // process
87 | result, err := provider.GetGasPrice()
88 |
89 | // verify
90 | assert.NoError(t, err)
91 | assert.Equal(t, 1, result.Cmp(big.NewInt(100)), "gas price should be greater than 100")
92 | }
93 |
--------------------------------------------------------------------------------
/internal/common/conv/argument.go:
--------------------------------------------------------------------------------
1 | package conv
2 |
3 | import (
4 | "math/big"
5 | "strconv"
6 |
7 | "github.com/ethereum/go-ethereum/accounts/abi"
8 | "github.com/ethereum/go-ethereum/common"
9 | "github.com/pkg/errors"
10 | )
11 |
12 | // PackArgument packs a single argument into a string
13 | func PackArgument(t abi.Type, v any) (string, error) {
14 | switch t.T {
15 | case abi.StringTy:
16 | if v, ok := v.(string); ok {
17 | return v, nil
18 | } else {
19 | return "", errors.Errorf("cannot convert %v to string", v)
20 | }
21 | case abi.IntTy, abi.UintTy:
22 | return formatInteger(t, v)
23 | case abi.BoolTy:
24 | if v, ok := v.(bool); ok {
25 | return strconv.FormatBool(v), nil
26 | } else {
27 | return "", errors.Errorf("cannot convert %v to bool", v)
28 | }
29 | case abi.AddressTy:
30 | if v, ok := v.(common.Address); ok {
31 | return v.Hex(), nil
32 | } else {
33 | return "", errors.Errorf("cannot convert %v to address", v)
34 | }
35 | case abi.HashTy:
36 | if v, ok := v.(common.Hash); ok {
37 | return v.Hex(), nil
38 | } else {
39 | return "", errors.Errorf("cannot convert %v to hash", v)
40 | }
41 | default:
42 | return "", errors.Errorf("unsupported argument type %v", t.T)
43 | }
44 | }
45 |
46 | // UnpackArgument converts string format of a value into the Go type corresponding to given argument type.
47 | func UnpackArgument(t abi.Type, s string) (any, error) {
48 | switch t.T {
49 | case abi.StringTy:
50 | return s, nil
51 | case abi.IntTy, abi.UintTy:
52 | return parseInteger(t, s)
53 | case abi.BoolTy:
54 | return strconv.ParseBool(s)
55 | case abi.AddressTy:
56 | return common.HexToAddress(s), nil
57 | case abi.HashTy:
58 | return common.HexToHash(s), nil
59 | default:
60 | return nil, errors.Errorf("unsupported argument type %v", t.T)
61 | }
62 | }
63 |
64 | func formatInteger(t abi.Type, v any) (string, error) {
65 | if t.T == abi.UintTy {
66 | switch t.Size {
67 | case 8, 16, 32, 64:
68 | if v, ok := v.(uint64); ok {
69 | return strconv.FormatUint(v, 10), nil
70 | } else {
71 | return "", errors.Errorf("cannot convert %v to uint64", v)
72 | }
73 | default:
74 | if v, ok := v.(*big.Int); ok {
75 | return v.String(), nil
76 | } else {
77 | return "", errors.Errorf("cannot convert %v to *big.Int", v)
78 | }
79 | }
80 | } else {
81 | switch t.Size {
82 | case 8, 16, 32, 64:
83 | if v, ok := v.(int64); ok {
84 | return strconv.FormatInt(v, 10), nil
85 | } else {
86 | return "", errors.Errorf("cannot convert %v to int64", v)
87 | }
88 | default:
89 | if v, ok := v.(*big.Int); ok {
90 | return v.String(), nil
91 | } else {
92 | return "", errors.Errorf("cannot convert %v to *big.Int", v)
93 | }
94 | }
95 | }
96 | }
97 |
98 | func parseInteger(t abi.Type, s string) (any, error) {
99 | if t.T == abi.UintTy {
100 | switch t.Size {
101 | case 8, 16, 32, 64:
102 | return strconv.ParseUint(s, 10, 64)
103 | default:
104 | i, ok := new(big.Int).SetString(s, 10)
105 | if !ok {
106 | return nil, errors.Errorf("cannot parse %s as type %v", s, t.T)
107 | } else {
108 | return i, nil
109 | }
110 | }
111 | } else {
112 | switch t.Size {
113 | case 8, 16, 32, 64:
114 | return strconv.ParseInt(s, 10, 64)
115 | default:
116 | i, ok := new(big.Int).SetString(s, 10)
117 | if !ok {
118 | return nil, errors.Errorf("cannot parse %s as type %v", s, t.T)
119 | } else {
120 | return i, nil
121 | }
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "strings"
8 |
9 | "github.com/dyng/ramen/internal/common"
10 | "github.com/dyng/ramen/internal/view/style"
11 | "github.com/pkg/errors"
12 | )
13 |
14 | var (
15 | DefaultProvider = "alchemy"
16 | DefaultNetwork = "mainnet"
17 | DefaultConfigFile = os.Getenv("HOME") + "/.ramen.json"
18 | )
19 |
20 | type configJSON struct {
21 | Provider *string `json:"provider,omitempty"`
22 | ApiKey *string `json:"apikey,omitempty"`
23 | EtherscanApiKey *string `json:"etherscanApikey,omitempty"`
24 | }
25 |
26 | type Config struct {
27 | // DebugMode controls the log level (debug or info)
28 | DebugMode bool
29 |
30 | // ConfigFile is the path of configuration file
31 | ConfigFile string
32 |
33 | // Provider is the JSON-RPC Provider's name
34 | Provider string
35 |
36 | // Network is the name of network to connect
37 | Network string
38 |
39 | // ApiKey is the key for the provider
40 | ApiKey string
41 |
42 | // EtherscanApiKey is the key for Etherscan API
43 | EtherscanApiKey string
44 | }
45 |
46 | func NewConfig() *Config {
47 | return &Config{}
48 | }
49 |
50 | // ParseConfig extract config file location from Config struct, read and parse
51 | // it, then overwrite Config struct in place.
52 | func ParseConfig(config *Config) error {
53 | // if config file does not exist, ignore
54 | path := config.ConfigFile
55 | bytes, err := os.ReadFile(path)
56 | if err != nil {
57 | if os.IsNotExist(err) {
58 | common.PrintMessage("Warning: config file %s does not exist. Create your own config file is highly recommended.", path)
59 | return nil
60 | } else {
61 | return errors.WithStack(err)
62 | }
63 | }
64 |
65 | // read and parse config file
66 | configJson := new(configJSON)
67 | err = json.Unmarshal(bytes, &configJson)
68 | if err != nil {
69 | return errors.WithStack(err)
70 | }
71 |
72 | // overwrite configurations only when the default value is used
73 | if configJson.Provider != nil && config.Provider == DefaultProvider {
74 | config.Provider = *configJson.Provider
75 | }
76 | if configJson.ApiKey != nil && config.ApiKey == "" {
77 | config.ApiKey = *configJson.ApiKey
78 | }
79 | if configJson.EtherscanApiKey != nil && config.EtherscanApiKey == "" {
80 | config.EtherscanApiKey = *configJson.EtherscanApiKey
81 | }
82 |
83 | return nil
84 | }
85 |
86 | // Validate validates the configuration.
87 | func (c *Config) Validate() error {
88 | // validate ApiKey
89 | if c.ApiKey == "" && c.Provider != "local" {
90 | return errors.New("ApiKey is required for non-local provider")
91 | }
92 |
93 | return nil
94 | }
95 |
96 | // Endpoint returns endpoint of given provider, respecting network to connect.
97 | func (c *Config) Endpoint() string {
98 | // config api key
99 | apiKey := c.ApiKey
100 |
101 | // config network
102 | switch c.Provider {
103 | case "local":
104 | return "ws://localhost:8545"
105 | case "alchemy":
106 | return fmt.Sprintf("wss://eth-%s.alchemyapi.io/v2/%s", strings.ToLower(c.Network), apiKey)
107 | default:
108 | return ""
109 | }
110 | }
111 |
112 | // EtherscanEndpoint returns endpoint of Etherscan API.
113 | func (c *Config) EtherscanEndpoint() string {
114 | if c.Network == "mainnet" {
115 | return fmt.Sprintf("https://api.etherscan.io/api")
116 | } else {
117 | return fmt.Sprintf("https://api-%s.etherscan.io/api", strings.ToLower(c.Network))
118 | }
119 | }
120 |
121 | func (c *Config) Style() *style.Style {
122 | return style.Ethereum
123 | }
124 |
--------------------------------------------------------------------------------
/internal/view/signin.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/view/format"
5 | "github.com/dyng/ramen/internal/view/style"
6 | "github.com/dyng/ramen/internal/view/util"
7 | "github.com/ethereum/go-ethereum/log"
8 | "github.com/gdamore/tcell/v2"
9 | "github.com/rivo/tview"
10 | )
11 |
12 | type SignInDialog struct {
13 | *tview.InputField
14 | app *App
15 | display bool
16 | lastFocus tview.Primitive
17 | spinner *util.Spinner
18 | }
19 |
20 | func NewSignInDialog(app *App) *SignInDialog {
21 | d := &SignInDialog{
22 | app: app,
23 | display: false,
24 | spinner: util.NewSpinner(app.Application),
25 | }
26 |
27 | // setup layout
28 | d.initLayout()
29 |
30 | return d
31 | }
32 |
33 | func (d *SignInDialog) initLayout() {
34 | s := d.app.config.Style()
35 |
36 | input := tview.NewInputField()
37 | input.SetFieldWidth(80)
38 | input.SetBorder(true)
39 | input.SetBorderColor(s.DialogBorderColor)
40 | input.SetTitle(style.Padding("Private Key"))
41 | input.SetTitleColor(s.FgColor)
42 | input.SetLabel(" ")
43 | input.SetMaskCharacter('*')
44 | input.SetFieldBackgroundColor(s.DialogBgColor)
45 | input.SetDoneFunc(d.handleKey)
46 | d.InputField = input
47 | }
48 |
49 | func (d *SignInDialog) handleKey(key tcell.Key) {
50 | switch key {
51 | case tcell.KeyEnter:
52 | // start spinner
53 | d.Loading()
54 |
55 | privateKey := d.GetText()
56 | if privateKey == "" {
57 | return
58 | }
59 |
60 | go func() {
61 | signer, err := d.app.service.GetSigner(privateKey)
62 | signer.UpdateBalance() // populate balance cache
63 | d.app.QueueUpdateDraw(func() {
64 | if err != nil {
65 | d.Finished()
66 | log.Error("Failed to create signer", "error", err)
67 | d.app.root.NotifyError(format.FineErrorMessage("Failed to create signer", err))
68 | } else {
69 | d.app.root.SignIn(signer)
70 | d.Finished()
71 | }
72 | })
73 | }()
74 | case tcell.KeyEsc:
75 | d.Hide()
76 | }
77 | }
78 |
79 | func (d *SignInDialog) Show() {
80 | if !d.display {
81 | // save last focused element
82 | d.lastFocus = d.app.GetFocus()
83 |
84 | d.Display(true)
85 | d.app.SetFocus(d)
86 | }
87 | }
88 |
89 | func (d *SignInDialog) Hide() {
90 | if d.display {
91 | d.Display(false)
92 | d.app.SetFocus(d.lastFocus)
93 | }
94 | }
95 |
96 | // Loading will set the location of spinner and show it
97 | func (d *SignInDialog) Loading() {
98 | d.setSpinnerRect()
99 | d.spinner.StartAndShow()
100 | }
101 |
102 | // Finished will stop and hide spinner, as well as close current dialog
103 | func (d *SignInDialog) Finished() {
104 | d.spinner.StopAndHide()
105 | d.Hide()
106 | }
107 |
108 | func (d *SignInDialog) Clear() {
109 | d.InputField.SetText("")
110 | }
111 |
112 | func (d *SignInDialog) Display(display bool) {
113 | d.display = display
114 | }
115 |
116 | func (d *SignInDialog) IsDisplay() bool {
117 | return d.display
118 | }
119 |
120 | // Draw implements tview.Primitive
121 | func (d *SignInDialog) Draw(screen tcell.Screen) {
122 | if d.display {
123 | d.InputField.Draw(screen)
124 | }
125 | d.spinner.Draw(screen)
126 | }
127 |
128 | func (d *SignInDialog) SetCentral(x int, y int, width int, height int) {
129 | inputWidth, inputHeight := d.inputSize()
130 | if inputWidth > width-2 {
131 | inputWidth = width - 2
132 | }
133 | if inputHeight > height-2 {
134 | inputHeight = height
135 | }
136 | ws := (width - inputWidth) / 2
137 | hs := (height - inputHeight) / 2
138 | d.InputField.SetRect(x+ws, y+hs, inputWidth, inputHeight)
139 | }
140 |
141 | func (d *SignInDialog) inputSize() (int, int) {
142 | width := len(d.GetLabel()) + d.GetFieldWidth()
143 | height := d.GetFieldHeight() + 2
144 | return width, height
145 | }
146 |
147 | func (d *SignInDialog) setSpinnerRect() {
148 | x, y, _, _ := d.GetInnerRect()
149 | sx := x + len(d.GetText()) + 1
150 | d.spinner.SetRect(sx, y, 0, 0)
151 | }
152 |
--------------------------------------------------------------------------------
/internal/view/query.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/view/format"
5 | "github.com/dyng/ramen/internal/view/style"
6 | "github.com/dyng/ramen/internal/view/util"
7 | "github.com/ethereum/go-ethereum/log"
8 | "github.com/gdamore/tcell/v2"
9 | "github.com/rivo/tview"
10 | )
11 |
12 | type QueryDialog struct {
13 | *tview.InputField
14 | app *App
15 | display bool
16 | spinner *util.Spinner
17 | }
18 |
19 | func NewQueryDialog(app *App) *QueryDialog {
20 | query := &QueryDialog{
21 | app: app,
22 | display: false,
23 | spinner: util.NewSpinner(app.Application),
24 | }
25 |
26 | // setup layout
27 | query.initLayout()
28 |
29 | return query
30 | }
31 |
32 | func (d *QueryDialog) initLayout() {
33 | s := d.app.config.Style()
34 |
35 | input := tview.NewInputField()
36 | input.SetFieldWidth(80)
37 | input.SetBorder(true)
38 | input.SetBorderColor(s.DialogBorderColor)
39 | input.SetTitle(style.Padding("Address"))
40 | input.SetTitleColor(s.FgColor)
41 | input.SetLabel("> ")
42 | input.SetLabelColor(s.InputFieldLableColor)
43 | input.SetFieldBackgroundColor(s.DialogBgColor)
44 | input.SetDoneFunc(d.handleKey)
45 | d.InputField = input
46 | }
47 |
48 | func (d *QueryDialog) handleKey(key tcell.Key) {
49 | switch key {
50 | case tcell.KeyEnter:
51 | // start spinner
52 | d.Loading()
53 |
54 | address := d.GetText()
55 | if address == "" {
56 | return
57 | }
58 |
59 | go func() {
60 | account, err := d.app.service.GetAccount(address)
61 | account.UpdateBalance() // populate balance cache
62 | d.app.QueueUpdateDraw(func() {
63 | if err != nil {
64 | d.Finished() // must stop loading animation before show error message
65 | log.Error("Failed to fetch account of given address",
66 | "address", address, "error", err)
67 | d.app.root.NotifyError(format.FineErrorMessage(
68 | "Failed to fetch account of address %s", address, err))
69 | } else {
70 | d.app.root.ShowAccountPage(account)
71 | d.Finished()
72 | }
73 |
74 | })
75 | }()
76 | case tcell.KeyEsc:
77 | d.Hide()
78 | }
79 | }
80 |
81 | func (d *QueryDialog) Show() {
82 | if !d.display {
83 | d.Display(true)
84 | d.app.SetFocus(d)
85 | }
86 | }
87 |
88 | func (d *QueryDialog) Hide() {
89 | if d.display {
90 | d.Display(false)
91 | d.app.SetFocus(d.app.root)
92 | }
93 | }
94 |
95 | // Loading will set the location of spinner and show it
96 | func (d *QueryDialog) Loading() {
97 | d.setSpinnerRect()
98 | d.spinner.StartAndShow()
99 | }
100 |
101 | // Finished will stop and hide spinner, as well as close current dialog
102 | func (d *QueryDialog) Finished() {
103 | d.spinner.StopAndHide()
104 | d.Hide()
105 | }
106 |
107 | func (d *QueryDialog) Clear() {
108 | d.InputField.SetText("")
109 | }
110 |
111 | func (d *QueryDialog) Display(display bool) {
112 | d.display = display
113 | }
114 |
115 | func (d *QueryDialog) IsDisplay() bool {
116 | return d.display
117 | }
118 |
119 | // Draw implements tview.Primitive
120 | func (d *QueryDialog) Draw(screen tcell.Screen) {
121 | if d.display {
122 | d.InputField.Draw(screen)
123 | }
124 | d.spinner.Draw(screen)
125 | }
126 |
127 | func (d *QueryDialog) SetCentral(x int, y int, width int, height int) {
128 | inputWidth, inputHeight := d.inputSize()
129 | if inputWidth > width-2 {
130 | inputWidth = width - 2
131 | }
132 | if inputHeight > height-2 {
133 | inputHeight = height
134 | }
135 | ws := (width - inputWidth) / 2
136 | hs := (height - inputHeight) / 2
137 | d.InputField.SetRect(x+ws, y+hs, inputWidth, inputHeight)
138 | }
139 |
140 | func (d *QueryDialog) inputSize() (int, int) {
141 | width := len(d.GetLabel()) + d.GetFieldWidth()
142 | height := d.GetFieldHeight() + 2
143 | return width, height
144 | }
145 |
146 | func (d *QueryDialog) setSpinnerRect() {
147 | x, y, _, _ := d.GetInnerRect()
148 | sx := x + len(d.GetText()) + 2
149 | d.spinner.SetRect(sx, y, 0, 0)
150 | }
151 |
--------------------------------------------------------------------------------
/internal/provider/etherscan/types.go:
--------------------------------------------------------------------------------
1 | package etherscan
2 |
3 | import (
4 | "encoding/json"
5 | "math/big"
6 |
7 | "github.com/dyng/ramen/internal/common"
8 | "github.com/dyng/ramen/internal/common/conv"
9 | "github.com/pkg/errors"
10 | )
11 |
12 | type resMessage struct {
13 | Status string `json:"status"`
14 | Message string `json:"message"`
15 | Result json.RawMessage `json:"result"`
16 | }
17 |
18 | type esTransaction struct {
19 | blockNumber common.BigInt
20 | timeStamp uint64
21 | hash common.Hash
22 | nonce uint64
23 | blockHash common.Hash
24 | transactionIndex uint
25 | from *common.Address
26 | to *common.Address
27 | value common.BigInt
28 | gas uint64
29 | gasPrice common.BigInt
30 | data []byte
31 | }
32 |
33 | // BlockNumber implements common.Transaction
34 | func (t *esTransaction) BlockNumber() common.BigInt {
35 | return t.blockNumber
36 | }
37 |
38 | // From implements common.Transaction
39 | func (t *esTransaction) From() *common.Address {
40 | return t.from
41 | }
42 |
43 | // Hash implements common.Transaction
44 | func (t *esTransaction) Hash() common.Hash {
45 | return t.hash
46 | }
47 |
48 | // Timestamp implements common.Transaction
49 | func (t *esTransaction) Timestamp() uint64 {
50 | return t.timeStamp
51 | }
52 |
53 | // To implements common.Transaction
54 | func (t *esTransaction) To() *common.Address {
55 | return t.to
56 | }
57 |
58 | // Value implements common.Transaction
59 | func (t *esTransaction) Value() common.BigInt {
60 | return t.value
61 | }
62 |
63 | // Data implements common.Transaction
64 | func (t *esTransaction) Data() []byte {
65 | return t.data
66 | }
67 |
68 | type txJSON struct {
69 | BlockNumber int64 `json:"blockNumber,string"`
70 | TimeStamp uint64 `json:"timeStamp,string"`
71 | Hash common.Hash `json:"hash"`
72 | Nonce uint64 `json:"nonce,string"`
73 | BlockHash common.Hash `json:"blockHash"`
74 | TransactionIndex uint `json:"transactionIndex,string"`
75 | From *common.Address `json:"from"`
76 | To *common.Address `json:"to"`
77 | Value string `json:"value"`
78 | Gas uint64 `json:"gas,string"`
79 | GasPrice string `json:"gasPrice"`
80 | IsError int64 `json:"isError,string"`
81 | TxReceiptStatus int64 `json:"txreceipt_status,string"`
82 | Input string `json:"input"`
83 | }
84 |
85 | func (t *esTransaction) UnmarshalJSON(input []byte) error {
86 | var tx txJSON
87 | err := json.Unmarshal(input, &tx)
88 | if err != nil {
89 | return errors.WithStack(err)
90 | }
91 |
92 | t.blockNumber = big.NewInt(tx.BlockNumber)
93 | t.timeStamp = tx.TimeStamp
94 | t.hash = tx.Hash
95 | t.nonce = tx.Nonce
96 | t.blockHash = tx.BlockHash
97 | t.transactionIndex = tx.TransactionIndex
98 | t.from = tx.From
99 | t.to = tx.To
100 | t.gas = tx.Gas
101 |
102 | bi, ok := new(big.Int).SetString(tx.Value, 10)
103 | if !ok {
104 | return errors.Errorf("cannot convert value %s to big.Int", tx.Value)
105 | }
106 | t.value = bi
107 |
108 | bi, ok = new(big.Int).SetString(tx.GasPrice, 10)
109 | if !ok {
110 | return errors.Errorf("cannot convert value %s to big.Int", tx.GasPrice)
111 | }
112 | t.gasPrice = bi
113 |
114 | bs, err := conv.HexToBytes(tx.Input)
115 | if err != nil {
116 | return errors.WithStack(err)
117 | }
118 | t.data = bs
119 |
120 | return nil
121 | }
122 |
123 | type contractJSON struct {
124 | SourceCode string `json:"SourceCode"`
125 | ABI string `json:"ABI"`
126 | ContractName string `json:"ContractName"`
127 | }
128 |
129 | type ethpriceJSON struct {
130 | EthBtc string `json:"ethbtc"`
131 | EthBtcTimestamp string `json:"ethbtc_timestamp"`
132 | EthUsd string `json:"ethusd"`
133 | EthUsdTimestamp string `json:"ethusd_timestamp"`
134 | }
135 |
--------------------------------------------------------------------------------
/internal/view/import_abi.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/view/format"
5 | "github.com/dyng/ramen/internal/view/style"
6 | "github.com/dyng/ramen/internal/view/util"
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/rivo/tview"
9 | )
10 |
11 | const (
12 | // importABIDialogMinHeight is the minimum height of the import ABI dialog.
13 | importABIDialogMinHeight = 16
14 | // importABIDialogMinWidth is the minimum width of the import ABI dialog.
15 | importABIDialogMinWidth = 50
16 | )
17 |
18 | type ImportABIDialog struct {
19 | *tview.Flex
20 | app *App
21 | display bool
22 | lastFocus tview.Primitive
23 |
24 | input *tview.TextArea
25 | button *tview.Button
26 | }
27 |
28 | func NewImportABIDialog(app *App) *ImportABIDialog {
29 | d := &ImportABIDialog{
30 | app: app,
31 | display: false,
32 | }
33 |
34 | // setup layout
35 | d.initLayout()
36 |
37 | // setup keymap
38 | d.initKeymap()
39 |
40 | return d
41 | }
42 |
43 | func (d *ImportABIDialog) initLayout() {
44 | s := d.app.config.Style()
45 |
46 | // description
47 | desc := tview.NewTextView()
48 | desc.SetWrap(true)
49 | desc.SetTextAlign(tview.AlignCenter)
50 | desc.SetBorderPadding(0, 0, 1, 1)
51 | desc.SetText("Cannot find ABI for this contract. But you can upload an ABI json instead.\nGenerate ABI json by solc command: `solc filename.sol --abi`.")
52 |
53 | // textarea
54 | input := tview.NewTextArea()
55 | input.SetBorder(true)
56 | input.SetBorderColor(s.BorderColor2)
57 | input.SetWrap(true)
58 | d.input = input
59 |
60 | // buttons
61 | buttons := tview.NewForm()
62 | buttons.SetButtonsAlign(tview.AlignRight)
63 | buttons.SetButtonBackgroundColor(s.ButtonBgColor)
64 | buttons.AddButton("Import", d.doImport)
65 | d.button = buttons.GetButton(0)
66 |
67 | // flex
68 | flex := tview.NewFlex().SetDirection(tview.FlexRow)
69 | flex.SetBorder(true)
70 | flex.SetBorderColor(s.DialogBorderColor)
71 | flex.SetTitle(style.BoldPadding("Import ABI"))
72 | flex.AddItem(desc, 0, 2, false)
73 | flex.AddItem(input, 0, 8, true)
74 | flex.AddItem(buttons, 3, 0, false)
75 |
76 | d.Flex = flex
77 | }
78 |
79 | func (d *ImportABIDialog) initKeymap() {
80 | InitKeymap(d, d.app)
81 | }
82 |
83 | // KeyMaps implements KeymapPrimitive
84 | func (d *ImportABIDialog) KeyMaps() util.KeyMaps {
85 | keymaps := make(util.KeyMaps, 0)
86 | keymaps = append(keymaps, util.NewSimpleKey(tcell.KeyEsc, d.Hide))
87 | keymaps = append(keymaps, util.NewSimpleKey(tcell.KeyTab, d.focusNext))
88 | return keymaps
89 | }
90 |
91 | func (d *ImportABIDialog) focusNext() {
92 | if d.input.HasFocus() {
93 | d.app.SetFocus(d.button)
94 | return
95 | }
96 |
97 | if d.button.HasFocus() {
98 | d.app.SetFocus(d.input)
99 | return
100 | }
101 | }
102 |
103 | func (d *ImportABIDialog) doImport() {
104 | account := d.app.root.account
105 |
106 | // read and parse abi json
107 | err := account.contract.ImportABI(d.input.GetText())
108 | if err != nil {
109 | d.app.root.NotifyError(format.FineErrorMessage("Cannot import ABI json", err))
110 | return
111 | }
112 |
113 | // hide dialog if importation complete
114 | d.Hide()
115 |
116 | // show callMethod dialog
117 | account.methodCall.refresh()
118 | account.ShowMethodCallDialog()
119 | }
120 |
121 | func (d *ImportABIDialog) Show() {
122 | if !d.display {
123 | // save last focused element
124 | d.lastFocus = d.app.GetFocus()
125 |
126 | d.Display(true)
127 | d.app.SetFocus(d)
128 | }
129 | }
130 |
131 | func (d *ImportABIDialog) Hide() {
132 | if d.display {
133 | d.Display(false)
134 | d.app.SetFocus(d.lastFocus)
135 | }
136 | }
137 |
138 | func (d *ImportABIDialog) Clear() {
139 | d.input.SetText("", true)
140 | }
141 |
142 | func (d *ImportABIDialog) Display(display bool) {
143 | d.display = display
144 | }
145 |
146 | func (d *ImportABIDialog) IsDisplay() bool {
147 | return d.display
148 | }
149 |
150 | // Draw implements tview.Primitive
151 | func (d *ImportABIDialog) Draw(screen tcell.Screen) {
152 | if d.display {
153 | d.Flex.Draw(screen)
154 | }
155 | }
156 |
157 | func (d *ImportABIDialog) SetCentral(x int, y int, width int, height int) {
158 | dialogWidth := width - width/2
159 | dialogHeight := height - height/2
160 | if dialogHeight < importABIDialogMinHeight {
161 | dialogHeight = importABIDialogMinHeight
162 | }
163 | if dialogWidth < importABIDialogMinWidth {
164 | dialogWidth = importABIDialogMinWidth
165 | }
166 | dialogX := x + ((width - dialogWidth) / 2)
167 | dialogY := y + ((height - dialogHeight) / 2)
168 | d.Flex.SetRect(dialogX, dialogY, dialogWidth, dialogHeight)
169 | }
170 |
--------------------------------------------------------------------------------
/internal/provider/etherscan/etherscan.go:
--------------------------------------------------------------------------------
1 | package etherscan
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "io/ioutil"
8 | "net/http"
9 | "strings"
10 | "time"
11 |
12 | "github.com/dyng/ramen/internal/common"
13 | "github.com/ethereum/go-ethereum/accounts/abi"
14 | "github.com/ethereum/go-ethereum/log"
15 | "github.com/pkg/errors"
16 | "github.com/shopspring/decimal"
17 | )
18 |
19 | const (
20 | // DefaultTimeout is the default value for request timeout
21 | // FIXME: code duplication
22 | DefaultTimeout = 30 * time.Second
23 | )
24 |
25 | type EtherscanClient struct {
26 | endpoint string
27 | apiKey string
28 | }
29 |
30 | func NewEtherscanClient(endpoint string, apiKey string) *EtherscanClient {
31 | return &EtherscanClient{
32 | endpoint: endpoint,
33 | apiKey: apiKey,
34 | }
35 | }
36 |
37 | func (c *EtherscanClient) AccountTxList(address common.Address) (common.Transactions, error) {
38 | req, err := http.NewRequest(http.MethodGet, c.endpoint, nil)
39 | if err != nil {
40 | return nil, errors.WithStack(err)
41 | }
42 |
43 | // build request
44 | q := req.URL.Query()
45 | q.Add("apikey", c.apiKey)
46 | q.Add("module", "account")
47 | q.Add("action", "txlist")
48 | q.Add("address", address.Hex())
49 | q.Add("startblock", "0")
50 | q.Add("endblock", "99999999")
51 | q.Add("sort", "desc")
52 | q.Add("page", "1")
53 | q.Add("offset", "100")
54 | req.URL.RawQuery = q.Encode()
55 |
56 | result, err := c.doRequest(req)
57 | if err != nil {
58 | return nil, err
59 | }
60 |
61 | esTxns := make([]*esTransaction, 0)
62 | if err = json.Unmarshal(result, &esTxns); err != nil {
63 | return nil, errors.WithStack(err)
64 | }
65 |
66 | txns := make(common.Transactions, len(esTxns))
67 | for i, et := range esTxns {
68 | txns[i] = et
69 | }
70 |
71 | return txns, nil
72 | }
73 |
74 | func (c *EtherscanClient) GetSourceCode(address common.Address) (string, *abi.ABI, error) {
75 | req, err := http.NewRequest(http.MethodGet, c.endpoint, nil)
76 | if err != nil {
77 | return "", nil, errors.WithStack(err)
78 | }
79 |
80 | // build request
81 | q := req.URL.Query()
82 | q.Add("apikey", c.apiKey)
83 | q.Add("module", "contract")
84 | q.Add("action", "getsourcecode")
85 | q.Add("address", address.Hex())
86 | req.URL.RawQuery = q.Encode()
87 |
88 | result, err := c.doRequest(req)
89 | if err != nil {
90 | return "", nil, err
91 | }
92 |
93 | var codes []contractJSON
94 | if err = json.Unmarshal(result, &codes); err != nil {
95 | return "", nil, errors.WithStack(err)
96 | }
97 |
98 | code := codes[0]
99 |
100 | // contract source code not verified
101 | if code.SourceCode == "" {
102 | return "", nil, nil
103 | }
104 |
105 | parsedAbi, err := abi.JSON(strings.NewReader(code.ABI))
106 | if err != nil {
107 | return "", nil, errors.WithStack(err)
108 | }
109 |
110 | return code.SourceCode, &parsedAbi, nil
111 | }
112 |
113 | func (c *EtherscanClient) EthPrice() (*decimal.Decimal, error) {
114 | req, err := http.NewRequest(http.MethodGet, c.endpoint, nil)
115 | if err != nil {
116 | return nil, errors.WithStack(err)
117 | }
118 |
119 | // build request
120 | q := req.URL.Query()
121 | q.Add("apikey", c.apiKey)
122 | q.Add("module", "stats")
123 | q.Add("action", "ethprice")
124 | req.URL.RawQuery = q.Encode()
125 |
126 | result, err := c.doRequest(req)
127 | if err != nil {
128 | return nil, err
129 | }
130 |
131 | var ethprice ethpriceJSON
132 | if err = json.Unmarshal(result, ðprice); err != nil {
133 | return nil, errors.WithStack(err)
134 | }
135 |
136 | price, err := decimal.NewFromString(ethprice.EthUsd)
137 | if err != nil {
138 | return nil, errors.WithStack(err)
139 | }
140 |
141 | return &price, nil
142 | }
143 |
144 | func (c *EtherscanClient) doRequest(request *http.Request) ([]byte, error) {
145 | ctx, cancel := c.createContext()
146 | defer cancel()
147 |
148 | // set timeout
149 | request = request.WithContext(ctx)
150 |
151 | res, err := http.DefaultClient.Do(request)
152 | if err != nil {
153 | return nil, errors.WithStack(err)
154 | }
155 |
156 | resBody, err := ioutil.ReadAll(res.Body)
157 | if err != nil {
158 | return nil, errors.WithStack(err)
159 | }
160 |
161 | if res.StatusCode != 200 {
162 | log.Error("HTTP status code is not OK", "status", res.Status, "body", resBody)
163 | return nil, errors.New("HTTP status code is not OK")
164 | }
165 |
166 | resMsg := resMessage{}
167 | if err = json.Unmarshal(resBody, &resMsg); err != nil {
168 | return nil, errors.WithStack(err)
169 | }
170 |
171 | if resMsg.Status == "0" {
172 | msg := string(resMsg.Result)
173 | log.Warn(fmt.Sprintf("Etherscan API status code is not OK. message is '%s'", msg))
174 | }
175 |
176 | return resMsg.Result, nil
177 | }
178 |
179 | func (c *EtherscanClient) createContext() (context.Context, context.CancelFunc) {
180 | return context.WithTimeout(context.Background(), DefaultTimeout)
181 | }
182 |
--------------------------------------------------------------------------------
/internal/view/util/key.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "github.com/gdamore/tcell/v2"
4 |
5 | func init() {
6 | initKeys()
7 | }
8 |
9 | func initKeys() {
10 | tcell.KeyNames[KeyHelp] = "?"
11 | tcell.KeyNames[KeySlash] = "/"
12 | tcell.KeyNames[KeySpace] = "space"
13 |
14 | initNumbKeys()
15 | initStdKeys()
16 | initShiftKeys()
17 | initShiftNumKeys()
18 | }
19 |
20 | // Defines numeric keys for container actions.
21 | const (
22 | Key0 tcell.Key = iota + 48
23 | Key1
24 | Key2
25 | Key3
26 | Key4
27 | Key5
28 | Key6
29 | Key7
30 | Key8
31 | Key9
32 | )
33 |
34 | // Defines numeric keys for container actions.
35 | const (
36 | KeyShift0 tcell.Key = 41
37 | KeyShift1 tcell.Key = 33
38 | KeyShift2 tcell.Key = 64
39 | KeyShift3 tcell.Key = 35
40 | KeyShift4 tcell.Key = 36
41 | KeyShift5 tcell.Key = 37
42 | KeyShift6 tcell.Key = 94
43 | KeyShift7 tcell.Key = 38
44 | KeyShift8 tcell.Key = 42
45 | KeyShift9 tcell.Key = 40
46 | )
47 |
48 | // Defines char keystrokes.
49 | const (
50 | KeyA tcell.Key = iota + 97
51 | KeyB
52 | KeyC
53 | KeyD
54 | KeyE
55 | KeyF
56 | KeyG
57 | KeyH
58 | KeyI
59 | KeyJ
60 | KeyK
61 | KeyL
62 | KeyM
63 | KeyN
64 | KeyO
65 | KeyP
66 | KeyQ
67 | KeyR
68 | KeyS
69 | KeyT
70 | KeyU
71 | KeyV
72 | KeyW
73 | KeyX
74 | KeyY
75 | KeyZ
76 | KeyHelp = 63
77 | KeySlash = 47
78 | KeyColon = 58
79 | KeySpace = 32
80 | )
81 |
82 | // Define Shift Keys.
83 | const (
84 | KeyShiftA tcell.Key = iota + 65
85 | KeyShiftB
86 | KeyShiftC
87 | KeyShiftD
88 | KeyShiftE
89 | KeyShiftF
90 | KeyShiftG
91 | KeyShiftH
92 | KeyShiftI
93 | KeyShiftJ
94 | KeyShiftK
95 | KeyShiftL
96 | KeyShiftM
97 | KeyShiftN
98 | KeyShiftO
99 | KeyShiftP
100 | KeyShiftQ
101 | KeyShiftR
102 | KeyShiftS
103 | KeyShiftT
104 | KeyShiftU
105 | KeyShiftV
106 | KeyShiftW
107 | KeyShiftX
108 | KeyShiftY
109 | KeyShiftZ
110 | )
111 |
112 | // NumKeys tracks number keys.
113 | var NumKeys = map[int]tcell.Key{
114 | 0: Key0,
115 | 1: Key1,
116 | 2: Key2,
117 | 3: Key3,
118 | 4: Key4,
119 | 5: Key5,
120 | 6: Key6,
121 | 7: Key7,
122 | 8: Key8,
123 | 9: Key9,
124 | }
125 |
126 | func initNumbKeys() {
127 | tcell.KeyNames[Key0] = "0"
128 | tcell.KeyNames[Key1] = "1"
129 | tcell.KeyNames[Key2] = "2"
130 | tcell.KeyNames[Key3] = "3"
131 | tcell.KeyNames[Key4] = "4"
132 | tcell.KeyNames[Key5] = "5"
133 | tcell.KeyNames[Key6] = "6"
134 | tcell.KeyNames[Key7] = "7"
135 | tcell.KeyNames[Key8] = "8"
136 | tcell.KeyNames[Key9] = "9"
137 | }
138 |
139 | func initStdKeys() {
140 | tcell.KeyNames[KeyA] = "a"
141 | tcell.KeyNames[KeyB] = "b"
142 | tcell.KeyNames[KeyC] = "c"
143 | tcell.KeyNames[KeyD] = "d"
144 | tcell.KeyNames[KeyE] = "e"
145 | tcell.KeyNames[KeyF] = "f"
146 | tcell.KeyNames[KeyG] = "g"
147 | tcell.KeyNames[KeyH] = "h"
148 | tcell.KeyNames[KeyI] = "i"
149 | tcell.KeyNames[KeyJ] = "j"
150 | tcell.KeyNames[KeyK] = "k"
151 | tcell.KeyNames[KeyL] = "l"
152 | tcell.KeyNames[KeyM] = "m"
153 | tcell.KeyNames[KeyN] = "n"
154 | tcell.KeyNames[KeyO] = "o"
155 | tcell.KeyNames[KeyP] = "p"
156 | tcell.KeyNames[KeyQ] = "q"
157 | tcell.KeyNames[KeyR] = "r"
158 | tcell.KeyNames[KeyS] = "s"
159 | tcell.KeyNames[KeyT] = "t"
160 | tcell.KeyNames[KeyU] = "u"
161 | tcell.KeyNames[KeyV] = "v"
162 | tcell.KeyNames[KeyW] = "w"
163 | tcell.KeyNames[KeyX] = "x"
164 | tcell.KeyNames[KeyY] = "y"
165 | tcell.KeyNames[KeyZ] = "z"
166 | }
167 |
168 | func initShiftNumKeys() {
169 | tcell.KeyNames[KeyShift0] = "Shift-0"
170 | tcell.KeyNames[KeyShift1] = "Shift-1"
171 | tcell.KeyNames[KeyShift2] = "Shift-2"
172 | tcell.KeyNames[KeyShift3] = "Shift-3"
173 | tcell.KeyNames[KeyShift4] = "Shift-4"
174 | tcell.KeyNames[KeyShift5] = "Shift-5"
175 | tcell.KeyNames[KeyShift6] = "Shift-6"
176 | tcell.KeyNames[KeyShift7] = "Shift-7"
177 | tcell.KeyNames[KeyShift8] = "Shift-8"
178 | tcell.KeyNames[KeyShift9] = "Shift-9"
179 | }
180 |
181 | func initShiftKeys() {
182 | tcell.KeyNames[KeyShiftA] = "Shift-A"
183 | tcell.KeyNames[KeyShiftB] = "Shift-B"
184 | tcell.KeyNames[KeyShiftC] = "Shift-C"
185 | tcell.KeyNames[KeyShiftD] = "Shift-D"
186 | tcell.KeyNames[KeyShiftE] = "Shift-E"
187 | tcell.KeyNames[KeyShiftF] = "Shift-F"
188 | tcell.KeyNames[KeyShiftG] = "Shift-G"
189 | tcell.KeyNames[KeyShiftH] = "Shift-H"
190 | tcell.KeyNames[KeyShiftI] = "Shift-I"
191 | tcell.KeyNames[KeyShiftJ] = "Shift-J"
192 | tcell.KeyNames[KeyShiftK] = "Shift-K"
193 | tcell.KeyNames[KeyShiftL] = "Shift-L"
194 | tcell.KeyNames[KeyShiftM] = "Shift-M"
195 | tcell.KeyNames[KeyShiftN] = "Shift-N"
196 | tcell.KeyNames[KeyShiftO] = "Shift-O"
197 | tcell.KeyNames[KeyShiftP] = "Shift-P"
198 | tcell.KeyNames[KeyShiftQ] = "Shift-Q"
199 | tcell.KeyNames[KeyShiftR] = "Shift-R"
200 | tcell.KeyNames[KeyShiftS] = "Shift-S"
201 | tcell.KeyNames[KeyShiftT] = "Shift-T"
202 | tcell.KeyNames[KeyShiftU] = "Shift-U"
203 | tcell.KeyNames[KeyShiftV] = "Shift-V"
204 | tcell.KeyNames[KeyShiftW] = "Shift-W"
205 | tcell.KeyNames[KeyShiftX] = "Shift-X"
206 | tcell.KeyNames[KeyShiftY] = "Shift-Y"
207 | tcell.KeyNames[KeyShiftZ] = "Shift-Z"
208 | }
209 |
--------------------------------------------------------------------------------
/internal/view/util/avatar.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "fmt"
5 | "hash/fnv"
6 |
7 | "github.com/dyng/ramen/internal/common"
8 | "github.com/ethereum/go-ethereum/log"
9 | "github.com/pkg/errors"
10 | "github.com/rivo/tview"
11 | "github.com/rrivera/identicon"
12 | )
13 |
14 | const (
15 | backgroundColor = "darkgrey"
16 | )
17 |
18 | var (
19 | palette = []string{
20 | "maroon",
21 | "green",
22 | "olive",
23 | "navy",
24 | "purple",
25 | "teal",
26 | "silver",
27 | "red",
28 | "lime",
29 | "yellow",
30 | "blue",
31 | "aqua",
32 | "aliceblue",
33 | "antiquewhite",
34 | "aquamarine",
35 | "azure",
36 | "beige",
37 | "bisque",
38 | "blanchedalmond",
39 | "blueviolet",
40 | "brown",
41 | "burlywood",
42 | "cadetblue",
43 | "chartreuse",
44 | "chocolate",
45 | "coral",
46 | "cornflowerblue",
47 | "cornsilk",
48 | "darkblue",
49 | "darkcyan",
50 | "darkgoldenrod",
51 | "darkgreen",
52 | "darkkhaki",
53 | "darkmagenta",
54 | "darkolivegreen",
55 | "darkorange",
56 | "darkorchid",
57 | "darkred",
58 | "darksalmon",
59 | "darkseagreen",
60 | "darkslateblue",
61 | "darkturquoise",
62 | "darkviolet",
63 | "deeppink",
64 | "deepskyblue",
65 | "dodgerblue",
66 | "firebrick",
67 | "floralwhite",
68 | "forestgreen",
69 | "gainsboro",
70 | "ghostwhite",
71 | "gold",
72 | "goldenrod",
73 | "greenyellow",
74 | "honeydew",
75 | "hotpink",
76 | "indianred",
77 | "indigo",
78 | "ivory",
79 | "khaki",
80 | "lavender",
81 | "lavenderblush",
82 | "lawngreen",
83 | "lemonchiffon",
84 | "lightblue",
85 | "lightcoral",
86 | "lightcyan",
87 | "lightgoldenrodyellow",
88 | "lightgreen",
89 | "lightpink",
90 | "lightsalmon",
91 | "lightseagreen",
92 | "lightskyblue",
93 | "lightsteelblue",
94 | "lightyellow",
95 | "limegreen",
96 | "linen",
97 | "mediumaquamarine",
98 | "mediumblue",
99 | "mediumorchid",
100 | "mediumpurple",
101 | "mediumseagreen",
102 | "mediumslateblue",
103 | "mediumspringgreen",
104 | "mediumturquoise",
105 | "mediumvioletred",
106 | "midnightblue",
107 | "mintcream",
108 | "mistyrose",
109 | "moccasin",
110 | "navajowhite",
111 | "oldlace",
112 | "olivedrab",
113 | "orange",
114 | "orangered",
115 | "orchid",
116 | "palegoldenrod",
117 | "palegreen",
118 | "paleturquoise",
119 | "palevioletred",
120 | "papayawhip",
121 | "peachpuff",
122 | "peru",
123 | "pink",
124 | "plum",
125 | "powderblue",
126 | "rebeccapurple",
127 | "rosybrown",
128 | "royalblue",
129 | "saddlebrown",
130 | "salmon",
131 | "sandybrown",
132 | "seagreen",
133 | "seashell",
134 | "sienna",
135 | "skyblue",
136 | "slateblue",
137 | "snow",
138 | "springgreen",
139 | "steelblue",
140 | "tan",
141 | "thistle",
142 | "tomato",
143 | "turquoise",
144 | "violet",
145 | "wheat",
146 | "whitesmoke",
147 | "yellowgreen",
148 | }
149 | )
150 |
151 | type Avatar struct {
152 | *tview.Table
153 | size int
154 | generator *identicon.Generator
155 | address common.Address
156 | }
157 |
158 | func NewAvatar(size int) *Avatar {
159 | ig, err := identicon.New("ramen", size, 3)
160 | if err != nil {
161 | log.Error("Failed to create identicon generator", "error", errors.WithStack(err))
162 | common.Exit("Cannot create identicon generator: %v", err)
163 | }
164 |
165 | return &Avatar{
166 | Table: tview.NewTable(),
167 | size: size,
168 | generator: ig,
169 | }
170 | }
171 |
172 | // SetAddress binds an account to this avatar widget, displays an identicon
173 | // generated by account's address.
174 | func (a *Avatar) SetAddress(address common.Address) {
175 | bitmap, color := a.identiconFrom(address)
176 | log.Debug("Generated avatar for account", "address", address, "bitmap", bitmap, "color", color)
177 |
178 | // render avatar
179 | for i := 0; i < a.size; i++ {
180 | text := ""
181 | for j := 0; j < a.size; j++ {
182 | if bitmap[i][j] > 0 {
183 | text += fmt.Sprintf("[%s]██[-]", color)
184 | } else {
185 | text += fmt.Sprintf("[%s]██[-]", backgroundColor)
186 | }
187 | }
188 | a.SetCell(i, 0, tview.NewTableCell(text))
189 | }
190 | }
191 |
192 | func (a *Avatar) identiconFrom(address common.Address) (bitmap [][]int, color string) {
193 | icon, err := a.generator.Draw(address.Hex())
194 | if err != nil {
195 | log.Error("Failed to generate identicon, fallback to default", "error", errors.WithStack(err))
196 | return a.defaultIdenticon()
197 | }
198 |
199 | bitmap = icon.Array()
200 | color = a.selectColor(address.Hex())
201 | return
202 | }
203 |
204 | func (a *Avatar) defaultIdenticon() (bitmap [][]int, color string) {
205 | color = backgroundColor
206 | bitmap = make([][]int, a.size)
207 | for i := 0; i < a.size; i++ {
208 | bitmap[i] = make([]int, a.size)
209 | for j := 0; j < a.size; j++ {
210 | bitmap[i][j] = 0
211 | }
212 | }
213 | return
214 | }
215 |
216 | func (a *Avatar) selectColor(text string) string {
217 | h := fnv.New32a()
218 | h.Write([]byte(text))
219 | i := h.Sum32()
220 | return palette[i%uint32(len(palette))]
221 | }
222 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Ramen - A Terminal Interface for Ethereum 🍜
2 |
3 | [](https://goreportcard.com/report/github.com/dyng/ramen)
4 | [](https://github.com/derailed/k9s/releases)
5 | [](https://github.com/mum4k/termdash/blob/master/LICENSE)
6 |
7 |
8 | Ramen is a good-old terminal UI to interact with [Ethereum Network](https://ethereum.org/en/). It allows you to observe latest chain status, check account's balance and transaction history, navigate blocks and transactions, view smart contract's source code or call its functions, and many things more!
9 |
10 | ## Features
11 |
12 | - [x] View an account's type, balance and transaction history.
13 | - [x] View transaction details, including sender/receiver address, value, input data, gas usage and timestamp.
14 | - [x] Decode transaction input data and display it in a human-readable format.
15 | - [x] Call contract functions.
16 | - [x] Import private key for transfer and calling of [non-constant](https://docs.ethers.org/v4/api-contract.html) functions.
17 | - [ ] View contract's [ABI](https://docs.soliditylang.org/en/v0.8.13/abi-spec.html), source code, and storage.
18 | - [x] Keep syncing with network to retrieve latest blocks and transactions.
19 | - [ ] Show account's assets, including [ERC20](https://ethereum.org/en/developers/docs/standards/tokens/erc-20/) tokens and [ERC721](https://ethereum.org/en/developers/docs/standards/tokens/erc-721/) NFTs.
20 | - [ ] Windows support.
21 | - [ ] [ENS](https://ens.domains/) support.
22 | - [ ] Navigate back and forth between pages.
23 | - [ ] Customize key bindings and color scheme.
24 | - [ ] Support more Ethereum JSON-RPC providers.
25 | - [ ] Support Polygon, Binance Smart Chain, and other EVM-compatible chains.
26 |
27 |
28 |
29 | Additionally, Ramen is also well designed for smart contract development. **Ramen can connect to a local chain (such as the one provided by [Hardhat](https://hardhat.org/))** to view transaction history of smart contract in development, call functions for testing, or verify its storage. Just works like [Etherscan](https://etherscan.io/), but for your own chain!
30 |
31 | ## Installation
32 |
33 | ### Using Package Manager
34 |
35 | #### Homebrew
36 |
37 | ```shell
38 | brew tap dyng/ramen && brew install ramen
39 | ```
40 |
41 | More package managers are coming soon!
42 |
43 | ### Using Prebuilt Binaries
44 |
45 | You can choose and download the prebuilt binary for your platform from [release page](https://github.com/dyng/ramen/releases).
46 |
47 | ### Building From Source
48 |
49 | If you want to experience the latest features, and don't mind the risk of running an unstable version, you can build Ramen from source.
50 |
51 | 1. Clone repository
52 |
53 | ```shell
54 | git clone https://github.com/dyng/ramen.git
55 | ```
56 |
57 | 2. Run `go build` command
58 |
59 | ```shell
60 | go build -o ramen
61 | ```
62 |
63 | ## Quick Start
64 |
65 | Ramen requires an Ethereum [JSON-RPC](https://ethereum.org/en/developers/docs/apis/json-rpc/) provider to communicate with Ethereum network. Currently only Alchemy and local node is supported by Ramen. More providers will be added soon.
66 |
67 | In addition to the Ethereum JSON-RPC provider, Ramen also relies on the Etherscan API to access certain information that is not easily obtainable through the JSON-RPC alone, such as transaction histories and ETH prices.
68 |
69 | To access Alchemy and Etherscan's service, you need an Api Key respectively. Please refer to their guides to obtain your own Api Key.
70 |
71 | - [Alchemy Quickstart Guide](https://docs.alchemy.com/lang-zh/docs/alchemy-quickstart-guide)
72 | - [Etherscan: Getting an API key](https://docs.etherscan.io/getting-started/viewing-api-usage-statistics)
73 |
74 | When the API keys are ready, you can create a configuration file `.ramen.json` in your home directory (e.g. `~/.ramen.json`) and place keys there.
75 |
76 | ```json
77 | {
78 | "apikey": "your_json_rpc_provider_api_key",
79 | "etherscanApikey": "your_etherscan_api_key"
80 | }
81 | ```
82 |
83 | Then you can start Ramen by running the following command:
84 |
85 | ```shell
86 | # connect to Mainnet
87 | ./ramen --network mainnet
88 |
89 | # connect to Goerli Testnet
90 | ./ramen --network goerli
91 | ```
92 |
93 | #### Key Bindings
94 |
95 | Ramen inherits key bindings from underlying UI framework [tview](https://github.com/rivo/tview), the most frequently used keys are the following:
96 |
97 | | Key | Action |
98 | |---|---|
99 | |`j`, `k`|Move cursor up and down|
100 | |`enter`|Select an element|
101 | |`tab`|Switch focus among elements|
102 |
103 | #### Connect Local Network
104 |
105 | [Hardhat](https://hardhat.org/) / [Ganache](https://trufflesuite.com/ganache/) provides a local Ethereum network for development purpose. Ramen can be used as an user interface for these local networks.
106 |
107 | ```shell
108 | ./ramen --provider local
109 | ```
110 |
111 | ## Troubleshoting
112 |
113 | If you come across some problems when using Ramen, please check the log file `/tmp/ramen.log` to see if there are any error messages. You can also run Ramen in debug mode with command:
114 |
115 | ```shell
116 | ramen --debug
117 | ```
118 |
119 | If you still can't figure out the problem, feel free to open an issue on [GitHub](https://github.com/dyng/ramen/issues/new)
120 |
121 | ## Special Thanks
122 |
123 | Ramen is built on top of many great open source projects, special thanks to [k9s](https://github.com/derailed/k9s) and [podman-tui](https://github.com/containers/podman-tui) for inspiration.
124 |
125 | ## License
126 |
127 | Ramen is released under the Apache 2.0 license. See LICENSE for details.
128 |
--------------------------------------------------------------------------------
/internal/view/account.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/common"
5 | "github.com/dyng/ramen/internal/common/conv"
6 | "github.com/dyng/ramen/internal/service"
7 | serv "github.com/dyng/ramen/internal/service"
8 | "github.com/dyng/ramen/internal/view/format"
9 | "github.com/dyng/ramen/internal/view/style"
10 | "github.com/dyng/ramen/internal/view/util"
11 | "github.com/ethereum/go-ethereum/log"
12 | "github.com/gdamore/tcell/v2"
13 | "github.com/rivo/tview"
14 | )
15 |
16 | type Account struct {
17 | *tview.Flex
18 | app *App
19 |
20 | accountInfo *AccountInfo
21 | transactionList *TransactionList
22 | methodCall *MethodCallDialog
23 | importABI *ImportABIDialog
24 | account *serv.Account
25 | contract *serv.Contract
26 | }
27 |
28 | type AccountInfo struct {
29 | *tview.Flex
30 | avatar *util.Avatar
31 | address *util.Section
32 | accountType *util.Section
33 | balance *util.Section
34 | }
35 |
36 | func NewAccount(app *App) *Account {
37 | account := &Account{
38 | app: app,
39 | }
40 |
41 | // setup layout
42 | account.initLayout()
43 |
44 | // setup keymap
45 | account.initKeymap()
46 |
47 | // subscribe to new blocks
48 | app.eventBus.Subscribe(service.TopicNewBlock, account.onNewBlock)
49 |
50 | return account
51 | }
52 |
53 | func (a *Account) SetAccount(account *serv.Account) {
54 | // change current account
55 | a.account = account
56 |
57 | // set base account
58 | base := a.account.GetAddress()
59 | a.transactionList.SetBaseAccount(&base)
60 |
61 | // populate contract field if account is a contract
62 | if account.IsContract() {
63 | contract, err := account.AsContract()
64 | if err == nil {
65 | a.contract = contract
66 | a.methodCall.SetContract(contract)
67 | } else {
68 | log.Error("Cannot upgrade account to contract", "account", account.GetAddress(), "error", err)
69 | a.app.root.NotifyError(format.FineErrorMessage("Cannot upgrade account to contract", err))
70 | }
71 | }
72 |
73 | // refresh
74 | a.refresh()
75 | }
76 |
77 | func (a *Account) initLayout() {
78 | s := a.app.config.Style()
79 |
80 | // AccountInfo
81 | accountInfo := &AccountInfo{
82 | Flex: tview.NewFlex(),
83 | avatar: util.NewAvatar(style.AvatarSize),
84 | address: util.NewSectionWithStyle("Address", util.NAValue, s),
85 | accountType: util.NewSectionWithStyle("Type", util.NAValue, s),
86 | balance: util.NewSectionWithStyle("Balance", util.NAValue, s),
87 | }
88 |
89 | info := tview.NewTable()
90 | accountInfo.accountType.AddToTable(info, 0, 0)
91 | accountInfo.address.AddToTable(info, 1, 0)
92 | accountInfo.balance.AddToTable(info, 2, 0)
93 |
94 | accountInfo.SetDirection(tview.FlexColumn)
95 | accountInfo.AddItem(accountInfo.avatar, style.AvatarSize*2+1, 0, false)
96 | accountInfo.AddItem(info, 0, 1, false)
97 | a.accountInfo = accountInfo
98 |
99 | // MethodCallDialog
100 | methodCall := NewMethodCallDialog(a.app)
101 | a.methodCall = methodCall
102 |
103 | // ImportABIDialog
104 | importABI := NewImportABIDialog(a.app)
105 | a.importABI = importABI
106 |
107 | // Transactions
108 | transactions := NewTransactionList(a.app, true)
109 | transactions.SetTitleColor(s.TitleColor2)
110 | transactions.SetBorderColor(s.BorderColor2)
111 | a.transactionList = transactions
112 |
113 | // Root
114 | flex := tview.NewFlex()
115 | flex.SetBorder(true)
116 | flex.SetTitle(style.BoldPadding("Account"))
117 | flex.SetBorderColor(s.BorderColor)
118 | flex.SetTitleColor(s.TitleColor)
119 | flex.SetDirection(tview.FlexRow)
120 | flex.AddItem(accountInfo, 0, 2, false)
121 | flex.AddItem(transactions, 0, 8, true)
122 | a.Flex = flex
123 | }
124 |
125 | func (a *Account) initKeymap() {
126 | InitKeymap(a, a.app)
127 | }
128 |
129 | func (a *Account) KeyMaps() util.KeyMaps {
130 | keymaps := make(util.KeyMaps, 0)
131 |
132 | // KeyC: call a contract
133 | keymaps = append(keymaps, util.KeyMap{
134 | Key: util.KeyC,
135 | Shortcut: "c",
136 | Description: "Call Contract",
137 | Handler: func(*tcell.EventKey) {
138 | // TODO: don't show "Call Contract" for wallet account
139 | if a.account.IsContract() {
140 | if a.methodCall.contract.HasABI() {
141 | a.ShowMethodCallDialog()
142 | } else {
143 | a.ShowImportABIDialog()
144 | }
145 | }
146 | },
147 | })
148 |
149 | return keymaps
150 | }
151 |
152 | func (a *Account) ShowMethodCallDialog() {
153 | if !a.account.IsContract() {
154 | return
155 | }
156 | a.methodCall.Clear()
157 | a.methodCall.Show()
158 | }
159 |
160 | func (a *Account) ShowImportABIDialog() {
161 | a.importABI.Clear()
162 | a.importABI.Show()
163 | }
164 |
165 | func (a *Account) onNewBlock(block *common.Block) {
166 | if a.account == nil {
167 | return
168 | }
169 |
170 | txns, err := a.app.service.GetTransactionsByBlock(block)
171 | if err != nil {
172 | log.Error("cannot extract transactions from block", "blockHash", block.Hash(), "error", err)
173 | return
174 | }
175 |
176 | // update current account
177 | a.account.UpdateBalance()
178 |
179 | a.app.QueueUpdateDraw(func() {
180 | a.refreshBalance()
181 | a.transactionList.FilterAndPrependTransactions(txns)
182 | })
183 | }
184 |
185 | func (a *Account) refresh() {
186 | addr := a.account.GetAddress()
187 | a.accountInfo.address.SetText(addr.Hex())
188 | a.accountInfo.accountType.SetText(StyledAccountType(a.account.GetType()))
189 |
190 | // avatar
191 | a.accountInfo.avatar.SetAddress(addr)
192 |
193 | // fetch balance
194 | bal := a.account.GetBalance()
195 | a.accountInfo.balance.SetText(conv.ToEther(bal).String())
196 |
197 | // update transaction history asynchronously
198 | a.transactionList.LoadAsync(a.account.GetTransactions)
199 | }
200 |
201 | func (a *Account) refreshBalance() {
202 | bal := a.account.GetBalance()
203 | a.accountInfo.balance.SetText(conv.ToEther(bal).String())
204 | }
205 |
206 | // Primitive Interface Implementation
207 |
208 | // HasFocus implements tview.Primitive
209 | func (a *Account) HasFocus() bool {
210 | if a.methodCall.HasFocus() {
211 | return true
212 | }
213 | if a.importABI.HasFocus() {
214 | return true
215 | }
216 | return a.Flex.HasFocus()
217 | }
218 |
219 | // InputHandler implements tview.Primitive
220 | func (a *Account) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
221 | return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
222 | if a.methodCall.HasFocus() {
223 | if handler := a.methodCall.InputHandler(); handler != nil {
224 | handler(event, setFocus)
225 | return
226 | }
227 | }
228 | if a.importABI.HasFocus() {
229 | if handler := a.importABI.InputHandler(); handler != nil {
230 | handler(event, setFocus)
231 | return
232 | }
233 | }
234 | if a.Flex.HasFocus() {
235 | if handler := a.Flex.InputHandler(); handler != nil {
236 | handler(event, setFocus)
237 | return
238 | }
239 | }
240 | }
241 | }
242 |
243 | // SetRect implements tview.SetRect
244 | func (a *Account) SetRect(x int, y int, width int, height int) {
245 | a.Flex.SetRect(x, y, width, height)
246 | a.methodCall.SetCentral(a.GetInnerRect())
247 | a.importABI.SetCentral(a.GetInnerRect())
248 | }
249 |
250 | // Draw implements tview.Primitive
251 | func (a *Account) Draw(screen tcell.Screen) {
252 | a.Flex.Draw(screen)
253 | a.methodCall.Draw(screen)
254 | a.importABI.Draw(screen)
255 | }
256 |
--------------------------------------------------------------------------------
/internal/view/transfer.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "fmt"
5 | "math/big"
6 |
7 | "github.com/dyng/ramen/internal/common/conv"
8 | "github.com/dyng/ramen/internal/service"
9 | "github.com/dyng/ramen/internal/view/format"
10 | "github.com/dyng/ramen/internal/view/style"
11 | "github.com/dyng/ramen/internal/view/util"
12 | gcommon "github.com/ethereum/go-ethereum/common"
13 | "github.com/ethereum/go-ethereum/log"
14 | "github.com/gdamore/tcell/v2"
15 | "github.com/rivo/tview"
16 | )
17 |
18 | const (
19 | // transferDialogMinHeight is the minimum height of the transfer dialog.
20 | transferDialogMinHeight = 10
21 | // transferDialogMinWidth is the minimum width of the transfer dialog.
22 | transferDialogMinWidth = 50
23 | )
24 |
25 | type TransferDialog struct {
26 | *tview.Form
27 | app *App
28 | display bool
29 | lastFocus tview.Primitive
30 |
31 | sender *service.Signer
32 | info *SenderFormItem
33 | to *tview.InputField
34 | amount *tview.InputField
35 | }
36 |
37 | func NewTransferDialog(app *App) *TransferDialog {
38 | d := &TransferDialog{
39 | app: app,
40 | display: false,
41 | }
42 |
43 | // setup layout
44 | d.initLayout()
45 |
46 | // setup keymap
47 | d.initKeymap()
48 |
49 | return d
50 | }
51 |
52 | func (d *TransferDialog) initLayout() {
53 | s := d.app.config.Style()
54 |
55 | // sender info
56 | info := NewSenderFormItem(d.app)
57 | d.info = info
58 |
59 | // form
60 | form := tview.NewForm()
61 | form.SetBorder(true)
62 | form.SetBorderColor(s.DialogBorderColor)
63 | form.SetTitle(style.BoldPadding("Transfer"))
64 | form.SetLabelColor(s.InputFieldLableColor)
65 | form.SetFieldBackgroundColor(s.InputFieldBgColor)
66 | form.SetButtonsAlign(tview.AlignRight)
67 | form.SetButtonBackgroundColor(s.ButtonBgColor)
68 | form.AddFormItem(info)
69 | form.AddInputField("To", "", 999, nil, nil)
70 | form.AddInputField("Amount", "", 999, nil, nil)
71 | form.AddButton("Transfer", d.doTransfer)
72 | d.to = form.GetFormItemByLabel("To").(*tview.InputField)
73 | d.amount = form.GetFormItemByLabel("Amount").(*tview.InputField)
74 | d.Form = form
75 | }
76 |
77 | func (d *TransferDialog) initKeymap() {
78 | InitKeymap(d, d.app)
79 | }
80 |
81 | // KeyMaps implements KeymapPrimitive
82 | func (d *TransferDialog) KeyMaps() util.KeyMaps {
83 | keymaps := make(util.KeyMaps, 0)
84 | keymaps = append(keymaps, util.NewSimpleKey(tcell.KeyEsc, d.Hide))
85 | return keymaps
86 | }
87 |
88 | func (d *TransferDialog) SetSender(account *service.Signer) {
89 | d.sender = account
90 | d.refresh()
91 | }
92 |
93 | func (d *TransferDialog) refresh() {
94 | // refresh sender's information (e.g. balance)
95 | d.info.SetSender(d.sender)
96 | }
97 |
98 | // doTransfer is core method that do the whole things
99 | func (d *TransferDialog) doTransfer() {
100 | i, ok := new(big.Float).SetString(d.amount.GetText())
101 | if !ok {
102 | d.app.root.NotifyError(fmt.Sprintf("Cannot parse amount value %s", d.amount.GetText()))
103 | return
104 | }
105 |
106 | // close dialog
107 | d.Hide()
108 |
109 | amount := conv.FromEther(i)
110 | toAddr := gcommon.HexToAddress(d.to.GetText())
111 | log.Info("Transfer ethers to another account", "from", d.sender.GetAddress(), "to", toAddr, "amount", amount)
112 |
113 | hash, err := d.sender.TransferTo(toAddr, amount)
114 | if err != nil {
115 | d.app.root.NotifyError(format.FineErrorMessage("Failed to complete transfer", err))
116 | } else {
117 | d.app.root.NotifyInfo(fmt.Sprintf("Transaction has been submitted.\n\nTxnHash: %s", hash))
118 | }
119 | }
120 |
121 | func (d *TransferDialog) Show() {
122 | if !d.display {
123 | // save last focused element
124 | d.lastFocus = d.app.GetFocus()
125 |
126 | d.Display(true)
127 | d.app.SetFocus(d)
128 | }
129 | }
130 |
131 | func (d *TransferDialog) Hide() {
132 | if d.display {
133 | d.Display(false)
134 | d.app.SetFocus(d.lastFocus)
135 | }
136 | }
137 |
138 | func (d *TransferDialog) ClearAndRefresh() {
139 | // clear
140 | d.to.SetText("")
141 | d.amount.SetText("")
142 |
143 | // refresh
144 | d.refresh()
145 | }
146 |
147 | func (d *TransferDialog) Display(display bool) {
148 | d.display = display
149 | }
150 |
151 | func (d *TransferDialog) IsDisplay() bool {
152 | return d.display
153 | }
154 |
155 | // Draw implements tview.Primitive
156 | func (d *TransferDialog) Draw(screen tcell.Screen) {
157 | if d.display {
158 | d.Form.Draw(screen)
159 | }
160 | }
161 |
162 | func (d *TransferDialog) SetCentral(x int, y int, width int, height int) {
163 | dialogWidth := width - width/2
164 | dialogHeight := style.AvatarSize + 12
165 | if dialogHeight < transferDialogMinHeight {
166 | dialogHeight = transferDialogMinHeight
167 | }
168 | if dialogWidth < transferDialogMinWidth {
169 | dialogWidth = transferDialogMinWidth
170 | }
171 | dialogX := x + ((width - dialogWidth) / 2)
172 | dialogY := y + ((height - dialogHeight) / 2)
173 | d.Form.SetRect(dialogX, dialogY, dialogWidth, dialogHeight)
174 | }
175 |
176 | type SenderFormItem struct {
177 | *tview.Flex
178 | app *App
179 |
180 | label *tview.Table
181 | lableCell *tview.TableCell
182 | field *tview.Flex
183 | avatar *util.Avatar
184 | address *util.Section
185 | balance *util.Section
186 | }
187 |
188 | func NewSenderFormItem(app *App) *SenderFormItem {
189 | fi := &SenderFormItem{
190 | app: app,
191 | avatar: util.NewAvatar(style.AvatarSize),
192 | }
193 | s := app.config.Style()
194 | table := tview.NewTable()
195 |
196 | // address
197 | address := util.NewSectionWithStyle("Address", util.EmptyValue, s)
198 | address.AddToTable(table, 0, 0)
199 | fi.address = address
200 |
201 | // balance
202 | balance := util.NewSectionWithStyle("Balance", util.EmptyValue, s)
203 | balance.AddToTable(table, 1, 0)
204 | fi.balance = balance
205 |
206 | // field
207 | field := tview.NewFlex().SetDirection(tview.FlexRow)
208 | field.AddItem(fi.avatar, style.AvatarSize, 0, false)
209 | field.AddItem(table, 2, 0, false)
210 | fi.field = field
211 |
212 | // label
213 | label := tview.NewTable()
214 | cell := tview.NewTableCell(fi.GetLabel())
215 | label.SetCell((fi.GetFieldHeight()-1)/2, 0, cell)
216 | fi.label = label
217 | fi.lableCell = cell
218 |
219 | // flex
220 | flex := tview.NewFlex().SetDirection(tview.FlexColumn)
221 | flex.AddItem(label, 1, 0, false)
222 | flex.AddItem(field, 0, 1, false)
223 | fi.Flex = flex
224 |
225 | return fi
226 | }
227 |
228 | func (s *SenderFormItem) SetSender(account *service.Signer) {
229 | addr := account.GetAddress()
230 |
231 | // avatar
232 | s.avatar.SetAddress(addr)
233 |
234 | // address
235 | s.address.SetText(addr.Hex())
236 |
237 | // balance
238 | bal := account.GetBalance()
239 | s.balance.SetText(conv.ToEther(bal).String())
240 | }
241 |
242 | // Focus implements tview.Primitive
243 | func (s *SenderFormItem) Focus(delegate func(p tview.Primitive)) {
244 | delegate(s.app.root.transfer.GetFormItemByLabel("To"))
245 | }
246 |
247 | // GetFieldHeight implements tview.FormItem
248 | func (s *SenderFormItem) GetFieldHeight() int {
249 | return style.AvatarSize + 2
250 | }
251 |
252 | // GetFieldWidth implements tview.FormItem
253 | func (s *SenderFormItem) GetFieldWidth() int {
254 | return 999
255 | }
256 |
257 | // GetLabel implements tview.FormItem
258 | func (s *SenderFormItem) GetLabel() string {
259 | return "From"
260 | }
261 |
262 | // SetFinishedFunc implements tview.FormItem
263 | func (s *SenderFormItem) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
264 | return s
265 | }
266 |
267 | // SetFormAttributes implements tview.FormItem
268 | func (s *SenderFormItem) SetFormAttributes(labelWidth int, labelColor tcell.Color, bgColor tcell.Color, fieldTextColor tcell.Color, fieldBgColor tcell.Color) tview.FormItem {
269 | s.lableCell.SetTextColor(labelColor)
270 | s.lableCell.SetBackgroundColor(bgColor)
271 | s.Flex.ResizeItem(s.label, labelWidth, 0)
272 | return s
273 | }
274 |
--------------------------------------------------------------------------------
/internal/view/transaction.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/dyng/ramen/internal/common"
8 | "github.com/dyng/ramen/internal/common/conv"
9 | "github.com/dyng/ramen/internal/view/format"
10 | "github.com/dyng/ramen/internal/view/style"
11 | "github.com/dyng/ramen/internal/view/util"
12 | "github.com/ethereum/go-ethereum/log"
13 | "github.com/gdamore/tcell/v2"
14 | "github.com/rivo/tview"
15 | )
16 |
17 | var (
18 | // width of first column
19 | indentation = len("BlockNumber")
20 | )
21 |
22 | type TransactionDetail struct {
23 | *tview.Flex
24 | app *App
25 |
26 | transaction common.Transaction
27 | hash *util.Section
28 | blockNumber *util.Section
29 | timestamp *util.Section
30 | from *util.Section
31 | to *util.Section
32 | value *util.Section
33 | data *util.Section
34 | calldata *CallData
35 | }
36 |
37 | func NewTransactionDetail(app *App) *TransactionDetail {
38 | td := &TransactionDetail{
39 | Flex: tview.NewFlex(),
40 | app: app,
41 | }
42 |
43 | // setup layout
44 | td.initLayout()
45 |
46 | // setup keymap
47 | td.initKeymap()
48 |
49 | return td
50 | }
51 |
52 | func (t *TransactionDetail) initLayout() {
53 | s := t.app.config.Style()
54 |
55 | t.SetDirection(tview.FlexRow)
56 | t.SetBorder(true)
57 | t.SetTitle(style.BoldPadding("Transaction Detail"))
58 | t.SetTitleColor(s.TitleColor)
59 | t.SetBorderColor(s.BorderColor)
60 |
61 | table := tview.NewTable()
62 |
63 | t.hash = util.NewSectionWithStyle("Hash", util.EmptyValue, s)
64 | t.hash.AddToTable(table, 0, 0)
65 |
66 | t.blockNumber = util.NewSectionWithStyle("BlockNumber", util.EmptyValue, s)
67 | t.blockNumber.AddToTable(table, 1, 0)
68 |
69 | t.timestamp = util.NewSectionWithStyle("Timestamp", util.EmptyValue, s)
70 | t.timestamp.AddToTable(table, 2, 0)
71 |
72 | t.from = util.NewSectionWithStyle("From", util.EmptyValue, s)
73 | t.from.AddToTable(table, 3, 0)
74 |
75 | t.to = util.NewSectionWithStyle("To", util.EmptyValue, s)
76 | t.to.AddToTable(table, 4, 0)
77 |
78 | t.value = util.NewSectionWithStyle("Value", util.EmptyValue, s)
79 | t.value.AddToTable(table, 5, 0)
80 |
81 | t.data = util.NewSectionWithStyle("Data", util.EmptyValue, s)
82 | t.data.AddToTable(table, 6, 0)
83 |
84 | t.calldata = NewCalldata(t.app)
85 |
86 | // add to layout
87 | t.AddItem(table, 7, 0, false)
88 | t.AddItem(t.calldata, 0, 1, false)
89 | }
90 |
91 | func (t *TransactionDetail) initKeymap() {
92 | InitKeymap(t, t.app)
93 | }
94 |
95 | func (t *TransactionDetail) KeyMaps() util.KeyMaps {
96 | keymaps := make(util.KeyMaps, 0)
97 |
98 | // KeyF: jump to sender's account page
99 | keymaps = append(keymaps, util.KeyMap{
100 | Key: util.KeyF,
101 | Shortcut: "f",
102 | Description: "To Sender",
103 | Handler: func(*tcell.EventKey) {
104 | t.ViewSender()
105 | },
106 | })
107 | // KeyT: jump to receiver's account page
108 | keymaps = append(keymaps, util.KeyMap{
109 | Key: util.KeyT,
110 | Shortcut: "t",
111 | Description: "To Receiver",
112 | Handler: func(*tcell.EventKey) {
113 | t.ViewReceiver()
114 | },
115 | })
116 |
117 | return keymaps
118 | }
119 |
120 | func (t *TransactionDetail) SetTransaction(transaction common.Transaction) {
121 | t.transaction = transaction
122 | t.refresh()
123 | }
124 |
125 | func (t *TransactionDetail) ViewSender() {
126 | log.Debug("View transaction sender", "transaction", t.transaction.Hash())
127 | t.viewAccount(t.from.GetText())
128 | }
129 |
130 | func (t *TransactionDetail) ViewReceiver() {
131 | log.Debug("View transaction receiver", "transaction", t.transaction.Hash())
132 | t.viewAccount(t.to.GetText())
133 | }
134 |
135 | func (t *TransactionDetail) refresh() {
136 | txn := t.transaction
137 | t.hash.SetText(txn.Hash().Hex())
138 | t.blockNumber.SetText(txn.BlockNumber().String())
139 | t.timestamp.SetText(format.ToDatetime(txn.Timestamp()))
140 | t.from.SetText(txn.From().Hex())
141 | t.to.SetText(format.NormalizeReceiverAddress(txn.To()))
142 | t.value.SetText(fmt.Sprintf("%s (%g Ether)", txn.Value(), conv.ToEther(txn.Value())))
143 | t.data.SetText(format.BytesToString(txn.Data(), 64))
144 | t.calldata.LoadAsync(t.transaction.To(), t.transaction.Data())
145 | }
146 |
147 | func (t *TransactionDetail) viewAccount(address string) {
148 | account, err := t.app.service.GetAccount(address)
149 | if err != nil {
150 | log.Error("Failed to fetch account of given address", "address", address, "error", err)
151 | t.app.root.NotifyError(format.FineErrorMessage(
152 | "Failed to fetch account of address %s", address, err))
153 | } else {
154 | t.app.root.ShowAccountPage(account)
155 | }
156 | }
157 |
158 | type CallData struct {
159 | *tview.Table
160 | app *App
161 | spinner *util.Spinner
162 | }
163 |
164 | func NewCalldata(app *App) *CallData {
165 | c := &CallData{
166 | Table: tview.NewTable(),
167 | app: app,
168 | spinner: util.NewSpinner(app.Application),
169 | }
170 | c.alignFirstColumn()
171 | return c
172 | }
173 |
174 | func (c *CallData) Clear() {
175 | c.Table.Clear()
176 | c.alignFirstColumn()
177 | }
178 |
179 | func (c *CallData) LoadAsync(address *common.Address, data []byte) {
180 | // clear previous data
181 | c.Clear()
182 |
183 | if len(data) > 0 && address != nil {
184 | // show spinner
185 | c.spinner.StartAndShow()
186 |
187 | go func() {
188 | // populate cache
189 | _, err := c.app.service.GetContract(*address)
190 | if err != nil {
191 | log.Error("Failed to fetch contract", "address", *address, "error", err)
192 | c.spinner.StopAndHide()
193 | } else {
194 | c.app.QueueUpdateDraw(func() {
195 | hasABI := c.parseData(*address, data)
196 | if !hasABI {
197 | c.warnNoABI()
198 | }
199 | c.spinner.StopAndHide()
200 | })
201 | }
202 | }()
203 | }
204 | }
205 |
206 | func (c *CallData) parseData(address common.Address, data []byte) bool {
207 | s := c.app.config.Style()
208 |
209 | if len(data) == 0 {
210 | return false
211 | }
212 |
213 | contract, err := c.app.service.GetContract(address)
214 | if err != nil {
215 | log.Error("Failed to fetch contract", "address", address, "error", err)
216 | return false
217 | }
218 |
219 | if !contract.HasABI() {
220 | return false
221 | }
222 |
223 | method, args, err := contract.ParseCalldata(data)
224 | if err != nil {
225 | log.Error("Failed to parse calldata", "address", address, "error", err)
226 | return false
227 | }
228 |
229 | // set method name
230 | c.SetCell(0, 1, tview.NewTableCell("[dodgerblue::b]function[-:-:-]"))
231 | c.SetCell(0, 2, tview.NewTableCell(method.Name).SetAttributes(tcell.AttrBold))
232 |
233 | // set arguments
234 | for i, argVal := range args {
235 | arg := method.Inputs[i]
236 |
237 | valStr, err := conv.PackArgument(arg.Type, argVal)
238 | if err != nil {
239 | log.Error("Failed to pack argument", "value", argVal, "type", arg.Type, "error", err)
240 | valStr = "ERROR"
241 | }
242 |
243 | c.SetCell(i+1, 0, tview.NewTableCell(""))
244 | c.SetCell(i+1, 1, tview.NewTableCell(arg.Name).SetTextColor(s.SectionColor2))
245 | c.SetCell(i+1, 2, tview.NewTableCell(valStr))
246 | }
247 |
248 | return true
249 | }
250 |
251 | func (c *CallData) warnNoABI() {
252 | c.SetCell(0, 1, tview.NewTableCell("[crimson]cannot decode calldata as ABI is unavailable[-]"))
253 | }
254 |
255 | func (c *CallData) setSpinnerRect() {
256 | x, y, _, _ := c.GetInnerRect()
257 | c.spinner.SetRect(x+indentation+1, y, 0, 0)
258 | }
259 |
260 | func (c *CallData) alignFirstColumn() {
261 | c.SetCell(0, 0, tview.NewTableCell(strings.Repeat(" ", indentation)))
262 | }
263 |
264 | // SetRect implements tview.SetRect
265 | func (c *CallData) SetRect(x int, y int, width int, height int) {
266 | c.Table.SetRect(x, y, width, height)
267 | c.setSpinnerRect()
268 | }
269 |
270 | // Draw implements tview.Primitive
271 | func (c *CallData) Draw(screen tcell.Screen) {
272 | c.Table.Draw(screen)
273 | c.spinner.Draw(screen)
274 | }
275 |
--------------------------------------------------------------------------------
/internal/view/root.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "github.com/dyng/ramen/internal/common"
5 | "github.com/dyng/ramen/internal/service"
6 | "github.com/dyng/ramen/internal/view/style"
7 | "github.com/dyng/ramen/internal/view/util"
8 | "github.com/ethereum/go-ethereum/log"
9 | "github.com/gdamore/tcell/v2"
10 | "github.com/rivo/tview"
11 | )
12 |
13 | type bodyPage interface {
14 | KeyMaps() util.KeyMaps
15 | }
16 |
17 | type Root struct {
18 | *tview.Flex
19 | app *App
20 |
21 | // header
22 | chainInfo *ChainInfo
23 | signer *Signer
24 | help *Help
25 |
26 | // body
27 | body *tview.Pages
28 | home *Home
29 | account *Account
30 | transaction *TransactionDetail
31 |
32 | // dialogs
33 | query *QueryDialog
34 | notification *Notification
35 | signin *SignInDialog
36 | transfer *TransferDialog
37 | }
38 |
39 | func NewRoot(app *App) *Root {
40 | root := &Root{
41 | app: app,
42 | }
43 |
44 | // setup layout
45 | root.initLayout()
46 |
47 | // setup keymap
48 | root.initKeymap()
49 |
50 | return root
51 | }
52 |
53 | func (r *Root) initLayout() {
54 | // chainInfo
55 | chainInfo := NewChainInfo(r.app)
56 | r.chainInfo = chainInfo
57 |
58 | // signer
59 | signer := NewSigner(r.app)
60 | r.signer = signer
61 |
62 | // help
63 | help := NewHelp(r.app)
64 | r.help = help
65 |
66 | // header
67 | header := tview.NewFlex().SetDirection(tview.FlexColumn)
68 | header.AddItem(chainInfo, 0, 6, false)
69 | header.AddItem(signer, 0, 6, false)
70 | header.AddItem(help, 0, 4, false)
71 |
72 | // body
73 | body := tview.NewPages()
74 | r.body = body
75 |
76 | // home page
77 | home := NewHome(r.app)
78 | body.AddPage("home", home, true, true)
79 | r.home = home
80 |
81 | // account page
82 | account := NewAccount(r.app)
83 | body.AddPage("account", account, true, false)
84 | r.account = account
85 |
86 | // transaction detail page
87 | transaction := NewTransactionDetail(r.app)
88 | body.AddPage("transaction", transaction, true, false)
89 | r.transaction = transaction
90 |
91 | // query dialog
92 | query := NewQueryDialog(r.app)
93 | r.query = query
94 |
95 | // notiication bar
96 | notification := NewNotification(r.app)
97 | r.notification = notification
98 |
99 | // signin dialog
100 | signin := NewSignInDialog(r.app)
101 | r.signin = signin
102 |
103 | // transfer dialog
104 | transfer := NewTransferDialog(r.app)
105 | r.transfer = transfer
106 |
107 | // root
108 | flex := tview.NewFlex().
109 | SetDirection(tview.FlexRow).
110 | AddItem(header, style.HeaderHeight, 0, false).
111 | AddItem(body, 0, 1, true)
112 | r.Flex = flex
113 | }
114 |
115 | func (r *Root) initKeymap() {
116 | InitKeymap(r, r.app)
117 | }
118 |
119 | func (r *Root) KeyMaps() util.KeyMaps {
120 | keymaps := make(util.KeyMaps, 0)
121 |
122 | // KeySlash: show a query dialog
123 | keymaps = append(keymaps, util.KeyMap{
124 | Key: util.KeySlash,
125 | Shortcut: "/",
126 | Description: "Search",
127 | Handler: func(*tcell.EventKey) {
128 | r.ShowQueryDialog()
129 | },
130 | })
131 |
132 | // KeyH: back to home
133 | keymaps = append(keymaps, util.KeyMap{
134 | Key: util.KeyH,
135 | Shortcut: "h",
136 | Description: "Home",
137 | Handler: func(*tcell.EventKey) {
138 | r.ShowHomePage()
139 | },
140 | })
141 |
142 | // KeyS: signin
143 | keymaps = append(keymaps, util.KeyMap{
144 | Key: util.KeyS,
145 | Shortcut: "s",
146 | Description: "Sign In",
147 | Handler: func(*tcell.EventKey) {
148 | r.ShowSignInDialog()
149 | },
150 | })
151 |
152 | // KeyM: transfer
153 | keymaps = append(keymaps, util.KeyMap{
154 | Key: util.KeyM,
155 | Shortcut: "m",
156 | Description: "Transfer",
157 | Handler: func(*tcell.EventKey) {
158 | r.ShowTransferDialog()
159 | },
160 | })
161 |
162 | // KeyCtrlC: quit
163 | keymaps = append(keymaps, util.KeyMap{
164 | Key: tcell.KeyCtrlC,
165 | Shortcut: "ctrl-c",
166 | Description: "Quit",
167 | Handler: func(*tcell.EventKey) {
168 | r.app.Stop()
169 | },
170 | })
171 |
172 | return keymaps
173 | }
174 |
175 | func (r *Root) ShowQueryDialog() {
176 | r.query.Clear()
177 | r.query.Show()
178 | }
179 |
180 | func (r *Root) NotifyInfo(message string) {
181 | r.ShowNotification("[lightgreen::b]INFO[-::-]", message)
182 | }
183 |
184 | func (r *Root) NotifyError(errmsg string) {
185 | r.ShowNotification("[crimson::b]ERROR[-::-]", errmsg)
186 | }
187 |
188 | func (r *Root) ShowNotification(title string, text string) {
189 | r.notification.SetContent(title, text)
190 | r.notification.Show()
191 | }
192 |
193 | func (r *Root) ShowSignInDialog() {
194 | r.signin.Clear()
195 | r.signin.Show()
196 | }
197 |
198 | func (r *Root) ShowTransferDialog() {
199 | if r.signer.HasSignedIn() {
200 | r.transfer.ClearAndRefresh()
201 | r.transfer.Show()
202 | }
203 | }
204 |
205 | func (r *Root) SignIn(signer *service.Signer) {
206 | log.Debug("Account signed in", "account", signer.GetAddress())
207 | r.signer.SetSigner(signer)
208 | r.transfer.SetSender(signer)
209 | }
210 |
211 | func (r *Root) ShowHomePage() {
212 | log.Debug("Switch to home page")
213 | r.body.SwitchToPage("home")
214 | r.updateHelp(r.home)
215 | }
216 |
217 | func (r *Root) ShowAccountPage(account *service.Account) {
218 | log.Debug("Switch to account page", "account", account.GetAddress())
219 | r.account.SetAccount(account)
220 | r.body.SwitchToPage("account")
221 | r.updateHelp(r.account)
222 | }
223 |
224 | func (r *Root) ShowTransactionPage(transaction common.Transaction) {
225 | log.Debug("Switch to transaction page", "transaction", transaction.Hash())
226 | r.transaction.SetTransaction(transaction)
227 | r.body.SwitchToPage("transaction")
228 | r.updateHelp(r.transaction)
229 | }
230 |
231 | func (r *Root) updateHelp(page bodyPage) {
232 | keymaps := r.KeyMaps().
233 | Add(page.KeyMaps())
234 |
235 | log.Debug("Update keys help", "keymaps", keymaps)
236 | r.help.SetKeyMaps(keymaps)
237 | }
238 |
239 | // Primitive Interface Implementation
240 |
241 | // HasFocus implements tview.Primitive
242 | func (r *Root) HasFocus() bool {
243 | if r.query.HasFocus() {
244 | return true
245 | }
246 | if r.signin.HasFocus() {
247 | return true
248 | }
249 | if r.transfer.HasFocus() {
250 | return true
251 | }
252 | if r.notification.HasFocus() {
253 | return true
254 | }
255 | return r.Flex.HasFocus()
256 | }
257 |
258 | // InputHandler implements tview.Primitive
259 | func (r *Root) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
260 | return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
261 | if r.Flex.HasFocus() {
262 | if handler := r.Flex.InputHandler(); handler != nil {
263 | handler(event, setFocus)
264 | return
265 | }
266 | }
267 | if r.query.HasFocus() {
268 | if handler := r.query.InputHandler(); handler != nil {
269 | handler(event, setFocus)
270 | return
271 | }
272 | }
273 | if r.signin.HasFocus() {
274 | if handler := r.signin.InputHandler(); handler != nil {
275 | handler(event, setFocus)
276 | return
277 | }
278 | }
279 | if r.transfer.HasFocus() {
280 | if handler := r.transfer.InputHandler(); handler != nil {
281 | handler(event, setFocus)
282 | return
283 | }
284 | }
285 | if r.notification.HasFocus() {
286 | if handler := r.notification.InputHandler(); handler != nil {
287 | handler(event, setFocus)
288 | return
289 | }
290 | }
291 | }
292 | }
293 |
294 | // SetRect implements tview.SetRect
295 | func (r *Root) SetRect(x int, y int, width int, height int) {
296 | r.Flex.SetRect(x, y, width, height)
297 | r.query.SetCentral(r.GetInnerRect())
298 | r.signin.SetCentral(r.GetInnerRect())
299 | r.transfer.SetCentral(r.GetInnerRect())
300 | r.notification.SetCentral(r.GetInnerRect())
301 | }
302 |
303 | // Draw implements tview.Primitive
304 | func (r *Root) Draw(screen tcell.Screen) {
305 | r.Flex.Draw(screen)
306 | r.query.Draw(screen)
307 | r.signin.Draw(screen)
308 | r.transfer.Draw(screen)
309 | r.notification.Draw(screen)
310 | }
311 |
--------------------------------------------------------------------------------
/internal/view/transactions.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/dyng/ramen/internal/common"
8 | "github.com/dyng/ramen/internal/common/conv"
9 | "github.com/dyng/ramen/internal/view/format"
10 | "github.com/dyng/ramen/internal/view/style"
11 | "github.com/dyng/ramen/internal/view/util"
12 | "github.com/ethereum/go-ethereum/log"
13 | "github.com/gdamore/tcell/v2"
14 | "github.com/rivo/tview"
15 | )
16 |
17 | const (
18 | // TransactionListLimit is the length limit of transaction list
19 | TransactionListLimit = 1000
20 | )
21 |
22 | type TransactionList struct {
23 | *tview.Table
24 | app *App
25 | txnPrev *TxnPreviewDialog
26 | loader *util.Loader
27 |
28 | showInOut bool
29 | base *common.Address
30 | txns common.Transactions
31 | }
32 |
33 | func NewTransactionList(app *App, showInOut bool) *TransactionList {
34 | t := &TransactionList{
35 | Table: tview.NewTable(),
36 | app: app,
37 | txnPrev: NewTxnPreviewDialog(app),
38 | loader: util.NewLoader(app.Application),
39 | showInOut: showInOut,
40 | txns: []common.Transaction{},
41 | }
42 |
43 | // setup layout
44 | t.initLayout()
45 |
46 | // setup keymap
47 | t.initKeymap()
48 |
49 | return t
50 | }
51 |
52 | func (t *TransactionList) initLayout() {
53 | s := t.app.config.Style()
54 |
55 | t.SetBorder(true)
56 | t.SetTitle(style.BoldPadding("Transactions"))
57 |
58 | // table
59 | var headers []string
60 | if t.showInOut {
61 | headers = []string{"hash", "block", "from", "to", "", "value", "datetime"}
62 | } else {
63 | headers = []string{"hash", "block", "from", "to", "value", "datetime"}
64 | }
65 | for i, header := range headers {
66 | t.SetCell(0, i,
67 | tview.NewTableCell(strings.ToUpper(header)).
68 | SetExpansion(1).
69 | SetAlign(tview.AlignLeft).
70 | SetStyle(s.TableHeaderStyle).
71 | SetSelectable(false))
72 | }
73 | t.SetSelectable(true, false)
74 | t.SetFixed(1, 1)
75 | t.SetSelectedFunc(t.handleSelected)
76 |
77 | // loader
78 | t.loader.SetTitleColor(s.PrgBarTitleColor)
79 | t.loader.SetBorderColor(s.PrgBarBorderColor)
80 | t.loader.SetCellColor(s.PrgBarCellColor)
81 | }
82 |
83 | func (t *TransactionList) initKeymap() {
84 | InitKeymap(t, t.app)
85 | }
86 |
87 | func (t *TransactionList) KeyMaps() util.KeyMaps {
88 | keymaps := make(util.KeyMaps, 0)
89 |
90 | // KeyF: jump to sender's account page
91 | keymaps = append(keymaps, util.KeyMap{
92 | Key: util.KeyF,
93 | Shortcut: "f",
94 | Description: "To Sender",
95 | Handler: func(*tcell.EventKey) {
96 | t.ViewSender()
97 | },
98 | })
99 | // KeyT: jump to receiver's account page
100 | keymaps = append(keymaps, util.KeyMap{
101 | Key: util.KeyT,
102 | Shortcut: "t",
103 | Description: "To Receiver",
104 | Handler: func(*tcell.EventKey) {
105 | t.ViewReceiver()
106 | },
107 | })
108 |
109 | return keymaps
110 | }
111 |
112 | // SetBaseAccount sets the base account to determine whether a transaction is
113 | // inflow or outflow
114 | func (t *TransactionList) SetBaseAccount(account *common.Address) {
115 | t.base = account
116 | }
117 |
118 | // FilterAndPrependTransactions is like PrependTransactions, but filters out
119 | // transactions that are not related to the base account
120 | func (t *TransactionList) FilterAndPrependTransactions(txns common.Transactions) {
121 | if t.base == nil {
122 | return
123 | }
124 |
125 | toAdd := make(common.Transactions, 0)
126 | for _, tx := range txns {
127 | if tx.From().String() == t.base.String() {
128 | toAdd = append(toAdd, tx)
129 | }
130 | if tx.To() != nil && tx.To().String() == t.base.String() {
131 | toAdd = append(toAdd, tx)
132 | }
133 | }
134 | t.PrependTransactions(toAdd)
135 | }
136 |
137 | // PrependTransactions prepends transactions to existing transactions
138 | func (t *TransactionList) PrependTransactions(txns common.Transactions) {
139 | prepended := append(txns, t.txns...)
140 | t.SetTransactions(prepended)
141 | }
142 |
143 | // SetTransactions sets a transaction list
144 | func (t *TransactionList) SetTransactions(txns common.Transactions) {
145 | if len(txns) > TransactionListLimit {
146 | txns = txns[:TransactionListLimit]
147 | }
148 | t.txns = txns
149 | t.refresh()
150 | }
151 |
152 | // LoadAsync loads transactions asynchronously
153 | func (t *TransactionList) LoadAsync(loader func() (common.Transactions, error)) {
154 | // clear current content
155 | t.Clear()
156 |
157 | // start loading animation
158 | t.loader.Start()
159 | t.loader.Display(true)
160 |
161 | go func() {
162 | txns, err := loader()
163 | t.app.QueueUpdateDraw(func() {
164 | // stop loading animation
165 | t.loader.Stop()
166 | t.loader.Display(false)
167 |
168 | if err == nil {
169 | if txns != nil {
170 | t.SetTransactions(txns)
171 | }
172 | } else {
173 | log.Error("Failed to load transactions", "error", err)
174 | t.app.root.NotifyError(format.FineErrorMessage("Error occurs when loading transactions.", err))
175 | }
176 | })
177 | }()
178 | }
179 |
180 | // ViewSender jumps to the sender's account page
181 | func (t *TransactionList) ViewSender() {
182 | current := t.selection()
183 | if current == nil {
184 | return
185 | }
186 |
187 | addr := current.From()
188 | if t.base != nil && addr.String() == t.base.String() {
189 | return
190 | }
191 |
192 | t.viewAccount(addr)
193 | }
194 |
195 | // ViewReceiver jumps to the receiver's account page
196 | func (t *TransactionList) ViewReceiver() {
197 | current := t.selection()
198 | if current == nil {
199 | return
200 | }
201 |
202 | addr := current.To()
203 | if t.base != nil && addr.String() == t.base.String() {
204 | return
205 | }
206 |
207 | t.viewAccount(addr)
208 | }
209 |
210 | func (t *TransactionList) Clear() {
211 | for i := t.GetRowCount() - 1; i > 0; i-- {
212 | t.RemoveRow(i)
213 | }
214 | }
215 |
216 | func (t *TransactionList) refresh() {
217 | // clear previous content at first
218 | t.Clear()
219 |
220 | // show transaction count
221 | t.SetTitle(style.BoldPadding(fmt.Sprintf("Transactions[[coral]%d[-]]", len(t.txns))))
222 |
223 | for i := 0; i < len(t.txns); i++ {
224 | tx := t.txns[i]
225 | row := i + 1
226 |
227 | j := 0
228 | t.SetCell(row, Inc(&j), tview.NewTableCell(format.TruncateText(tx.Hash().Hex(), 8)))
229 | t.SetCell(row, Inc(&j), tview.NewTableCell(tx.BlockNumber().String()))
230 | t.SetCell(row, Inc(&j), tview.NewTableCell(format.TruncateText(tx.From().Hex(), 20)))
231 | t.SetCell(row, Inc(&j), tview.NewTableCell(format.TruncateText(
232 | format.NormalizeReceiverAddress(tx.To()), 20)))
233 | if t.showInOut {
234 | t.SetCell(row, Inc(&j), tview.NewTableCell(StyledTxnDirection(t.base, tx)))
235 | }
236 | t.SetCell(row, Inc(&j), tview.NewTableCell(conv.ToEther(tx.Value()).String()))
237 | t.SetCell(row, Inc(&j), tview.NewTableCell(format.ToDatetime(tx.Timestamp())))
238 | }
239 | }
240 |
241 | // handleSelected shows a preview of selected transaction
242 | func (t *TransactionList) handleSelected(row int, column int) {
243 | if row > 0 && row <= len(t.txns) {
244 | txn := t.txns[row-1]
245 | t.txnPrev.SetTransaction(txn)
246 | t.txnPrev.Show()
247 | }
248 | }
249 |
250 | func (t *TransactionList) viewAccount(address *common.Address) {
251 | if address == nil {
252 | return
253 | }
254 |
255 | account, err := t.app.service.GetAccount(address.Hex())
256 | if err != nil {
257 | log.Error("Failed to fetch account of given address", "address", address.Hex(), "error", err)
258 | t.app.root.NotifyError(format.FineErrorMessage(
259 | "Failed to fetch account of address %s", address.Hex(), err))
260 | } else {
261 | t.txnPrev.Hide() // hide dialog if it's visible
262 | t.app.root.ShowAccountPage(account)
263 | }
264 | }
265 |
266 | func (t *TransactionList) selection() common.Transaction {
267 | row, _ := t.GetSelection()
268 | if row > 0 && row <= len(t.txns) {
269 | return t.txns[row-1]
270 | } else {
271 | return nil
272 | }
273 | }
274 |
275 | // HasFocus implements tview.Primitive
276 | func (t *TransactionList) HasFocus() bool {
277 | if t.txnPrev.HasFocus() {
278 | return true
279 | }
280 | return t.Table.HasFocus()
281 | }
282 |
283 | // InputHandler implements tview.Primitive
284 | func (t *TransactionList) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
285 | return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
286 | if t.txnPrev.HasFocus() {
287 | if handler := t.txnPrev.InputHandler(); handler != nil {
288 | handler(event, setFocus)
289 | return
290 | }
291 | }
292 | if t.Table.HasFocus() {
293 | if handler := t.Table.InputHandler(); handler != nil {
294 | handler(event, setFocus)
295 | return
296 | }
297 | }
298 | }
299 | }
300 |
301 | // SetRect implements tview.SetRect
302 | func (t *TransactionList) SetRect(x, y, width, height int) {
303 | t.Table.SetRect(x, y, width, height)
304 | t.txnPrev.SetCentral(x, y, width, height)
305 | t.loader.SetCentral(x, y, width, height)
306 | }
307 |
308 | // Draw implements tview.Draw
309 | func (t *TransactionList) Draw(screen tcell.Screen) {
310 | t.Table.Draw(screen)
311 | t.txnPrev.Draw(screen)
312 | t.loader.Draw(screen)
313 | }
314 |
--------------------------------------------------------------------------------
/internal/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "embed"
5 | "encoding/json"
6 | "math/big"
7 | "time"
8 |
9 | "github.com/dyng/ramen/internal/common"
10 | "github.com/dyng/ramen/internal/common/conv"
11 | conf "github.com/dyng/ramen/internal/config"
12 | "github.com/dyng/ramen/internal/provider"
13 | "github.com/dyng/ramen/internal/provider/etherscan"
14 | gcommon "github.com/ethereum/go-ethereum/common"
15 | "github.com/ethereum/go-ethereum/crypto"
16 | "github.com/ethereum/go-ethereum/log"
17 | "github.com/patrickmn/go-cache"
18 | "github.com/pkg/errors"
19 | "github.com/shopspring/decimal"
20 | )
21 |
22 | //go:embed data/chains.json
23 | var chainFile embed.FS
24 |
25 | var chainMap map[string]Network
26 |
27 | func init() {
28 | bytes, err := chainFile.ReadFile("data/chains.json")
29 | if err != nil {
30 | log.Error("Cannot read chains.json", "error", errors.WithStack(err))
31 | common.Exit("Cannot read chains.json: %v", err)
32 | }
33 |
34 | var networks []Network
35 | err = json.Unmarshal(bytes, &networks)
36 | if err != nil {
37 | log.Error("Cannot parse chains.json", "error", errors.WithStack(err))
38 | common.Exit("Cannot parse chains.json: %v", err)
39 | }
40 |
41 | cache := make(map[string]Network)
42 | for _, n := range networks {
43 | cache[n.ChainId.String()] = n
44 | }
45 |
46 | chainMap = cache
47 | }
48 |
49 | type Service struct {
50 | config *conf.Config
51 | esclient *etherscan.EtherscanClient
52 | provider *provider.Provider
53 | cache *cache.Cache
54 | }
55 |
56 | func NewService(config *conf.Config) *Service {
57 | service := Service{
58 | config: config,
59 | esclient: etherscan.NewEtherscanClient(config.EtherscanEndpoint(), config.EtherscanApiKey),
60 | provider: provider.NewProvider(config.Endpoint(), config.Provider),
61 | cache: cache.New(5*time.Minute, 10*time.Minute), // default cache expiration is 5 minutes
62 | }
63 |
64 | return &service
65 | }
66 |
67 | // GetProvider returns underlying provider instance.
68 | // Usually you don't need to tackle with provider directly.
69 | func (s *Service) GetProvider() *provider.Provider {
70 | return s.provider
71 | }
72 |
73 | // GetNetwork returns the network that provider is connected to.
74 | func (s *Service) GetNetwork() Network {
75 | chainId, _ := s.provider.GetNetwork()
76 | network, ok := chainMap[chainId.String()]
77 | if !ok {
78 | return Network{
79 | Name: "Unknown",
80 | Title: "Unknown",
81 | ChainId: chainId,
82 | }
83 | } else {
84 | return network
85 | }
86 | }
87 |
88 | // GetBlockHeight returns the current block height.
89 | func (s *Service) GetBlockHeight() (uint64, error) {
90 | return s.provider.GetBlockHeight()
91 | }
92 |
93 | // GetGasPrice returns average gas price of last block.
94 | func (s *Service) GetGasPrice() (common.BigInt, error) {
95 | return s.provider.GetGasPrice()
96 | }
97 |
98 | // GetEthPrice returns ETH price in USD.
99 | func (s *Service) GetEthPrice() (*decimal.Decimal, error) {
100 | return s.esclient.EthPrice()
101 | }
102 |
103 | // GetAccount returns an account of given address.
104 | func (s *Service) GetAccount(address string) (*Account, error) {
105 | addr := gcommon.HexToAddress(address)
106 |
107 | // return cached account if exists
108 | if account, found := s.GetCache(addr, TypeWallet); found {
109 | a := account.(*Account)
110 | a.ClearCache()
111 | return a, nil
112 | }
113 |
114 | code, err := s.provider.GetCode(addr)
115 | if err != nil {
116 | return nil, err
117 | }
118 |
119 | a := &Account{
120 | service: s,
121 | address: addr,
122 | code: code,
123 | }
124 | s.SetCache(addr, TypeWallet, a, cache.NoExpiration)
125 |
126 | return a, nil
127 | }
128 |
129 | // GetLatestTransactions returns last n transactions of at most nBlock blocks.
130 | func (s *Service) GetLatestTransactions(n int, nBlock int) (common.Transactions, error) {
131 | max, err := s.GetBlockHeight()
132 | if err != nil {
133 | return nil, err
134 | }
135 |
136 | min := uint64(1)
137 | cnt := uint64(nBlock)
138 | if max > cnt-1 {
139 | min = max - cnt + 1
140 | }
141 |
142 | transactions := make([]common.Transaction, 0)
143 | for i := max; i >= min; i-- {
144 | block, err := s.provider.GetBlockByNumber(new(big.Int).SetUint64(i))
145 | if err != nil {
146 | return transactions, err
147 | }
148 |
149 | txns, err := s.GetTransactionsByBlock(block)
150 | if err != nil {
151 | return transactions, err
152 | }
153 |
154 | transactions = append(transactions, txns...)
155 |
156 | if len(transactions) >= n {
157 | break
158 | }
159 | }
160 |
161 | return transactions, nil
162 | }
163 |
164 | // GetTransactionsByBlock returns transactions of given block hash.
165 | func (s *Service) GetTransactionsByBlock(block *common.Block) (common.Transactions, error) {
166 | signer, err := s.provider.GetSigner()
167 | if err != nil {
168 | return nil, err
169 | }
170 |
171 | txns := make(common.Transactions, block.Transactions().Len())
172 | for i, tx := range block.Transactions() {
173 | sender, err := signer.Sender(tx)
174 | if err != nil {
175 | return nil, errors.WithStack(err)
176 | }
177 | txns[i] = common.WrapTransactionWithBlock(tx, block, &sender)
178 | }
179 |
180 | return txns, nil
181 | }
182 |
183 | // GetTransactionHistory returns transactions related to specified account.
184 | // This method relies on Etherscan API at chains other than local chain.
185 | func (s *Service) GetTransactionHistory(address common.Address) (common.Transactions, error) {
186 | netType := s.GetNetwork().NetType()
187 | switch netType {
188 | case TypeDevnet:
189 | return s.transactionsByTraverse(address)
190 | default:
191 | return s.transactionsByEtherscan(address)
192 | }
193 | }
194 |
195 | func (s *Service) transactionsByTraverse(address common.Address) (common.Transactions, error) {
196 | candidates, err := s.GetLatestTransactions(100, 5)
197 | if err != nil {
198 | return nil, err
199 | }
200 |
201 | txns := make([]common.Transaction, 0)
202 | for _, t := range candidates {
203 | if t.From().String() == address.String() {
204 | txns = append(txns, t)
205 | }
206 | if t.To() != nil && t.To().String() == address.String() {
207 | txns = append(txns, t)
208 | }
209 | }
210 |
211 | return txns, nil
212 | }
213 |
214 | func (s *Service) transactionsByEtherscan(address common.Address) (common.Transactions, error) {
215 | return s.esclient.AccountTxList(address)
216 | }
217 |
218 | func (s *Service) transactionsByAlchemy(address common.Address) (common.Transactions, error) {
219 | hashList := make([]common.Hash, 0)
220 |
221 | // incoming transactions
222 | params := provider.GetAssetTransfersParams{
223 | FromAddress: address.Hex(),
224 | Category: []string{"external"},
225 | Order: "desc",
226 | MaxCount: "0x14", // decimal value: 20
227 | }
228 | result, err := s.provider.GetAssetTransfers(params)
229 | if err != nil {
230 | return nil, err
231 | }
232 | transfers := result.Transfers
233 | for _, tr := range transfers {
234 | hashList = append(hashList, gcommon.HexToHash(tr.Hash))
235 | }
236 |
237 | // outgoing transactions
238 | params = provider.GetAssetTransfersParams{
239 | ToAddress: address.Hex(),
240 | Category: []string{"external"},
241 | Order: "desc",
242 | MaxCount: "0x14", // decimal value: 20
243 | }
244 | result, err = s.provider.GetAssetTransfers(params)
245 | if err != nil {
246 | return nil, err
247 | }
248 | transfers = result.Transfers
249 | for _, tr := range transfers {
250 | hashList = append(hashList, gcommon.HexToHash(tr.Hash))
251 | }
252 |
253 | txns, err := s.provider.BatchTransactionByHash(hashList)
254 | if err != nil {
255 | return nil, err
256 | }
257 |
258 | return txns, nil
259 | }
260 |
261 | // GetContract returns a contract object of given address.
262 | func (s *Service) GetContract(address common.Address) (*Contract, error) {
263 | // return cached contract if exists
264 | if contract, found := s.GetCache(address, TypeContract); found {
265 | c := contract.(*Contract)
266 | c.ClearCache()
267 | return c, nil
268 | }
269 |
270 | account, err := s.GetAccount(address.Hex())
271 | if err != nil {
272 | return nil, err
273 | }
274 |
275 | return s.ToContract(account)
276 | }
277 |
278 | // GetSigner returns a signer which can sign transactions
279 | func (s *Service) GetSigner(privateKey string) (*Signer, error) {
280 | privKey, err := crypto.HexToECDSA(conv.Trim0xPrefix(privateKey))
281 | if err != nil {
282 | return nil, errors.WithStack(err)
283 | }
284 |
285 | // only EOA can have private key
286 | addr := crypto.PubkeyToAddress(privKey.PublicKey)
287 | account := &Account{
288 | service: s,
289 | address: addr,
290 | }
291 |
292 | signer := &Signer{
293 | Account: account,
294 | PrivateKey: privKey,
295 | }
296 | return signer, nil
297 | }
298 |
299 | // ToContract upgrade an account object to a contract.
300 | func (s *Service) ToContract(account *Account) (*Contract, error) {
301 | // return cached contract if exists
302 | if contract, found := s.GetCache(account.address, TypeContract); found {
303 | c := contract.(*Contract)
304 | c.ClearCache()
305 | return c, nil
306 | }
307 |
308 | if account.GetType() != TypeContract {
309 | return nil, errors.Errorf("Address %s is not a contract account", account.address.Hex())
310 | }
311 |
312 | var contract *Contract
313 |
314 | if s.GetNetwork().NetType() == TypeDevnet {
315 | contract = &Contract{
316 | Account: account,
317 | }
318 | } else {
319 | source, abi, err := s.esclient.GetSourceCode(account.address)
320 | if err != nil {
321 | return nil, err
322 | }
323 |
324 | contract = &Contract{
325 | Account: account,
326 | abi: abi,
327 | source: source,
328 | }
329 | }
330 |
331 | // populate cache
332 | s.SetCache(account.address, TypeContract, contract, cache.NoExpiration)
333 |
334 | return contract, nil
335 | }
336 |
--------------------------------------------------------------------------------
/internal/view/method_call.go:
--------------------------------------------------------------------------------
1 | package view
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 |
7 | "github.com/dyng/ramen/internal/common/conv"
8 | "github.com/dyng/ramen/internal/service"
9 | "github.com/dyng/ramen/internal/view/format"
10 | "github.com/dyng/ramen/internal/view/style"
11 | "github.com/dyng/ramen/internal/view/util"
12 | "github.com/ethereum/go-ethereum/accounts/abi"
13 | "github.com/ethereum/go-ethereum/log"
14 | "github.com/gdamore/tcell/v2"
15 | "github.com/rivo/tview"
16 | )
17 |
18 | const (
19 | // methodCallDialogMinHeight is the minimum height of the method call dialog.
20 | methodCallDialogMinHeight = 16
21 | // methodCallDialogMinWidth is the minimum width of the method call dialog.
22 | methodCallDialogMinWidth = 80
23 | )
24 |
25 | type MethodCallDialog struct {
26 | *tview.Flex
27 | app *App
28 | display bool
29 |
30 | methods *tview.Table
31 | args *tview.Form
32 | result *tview.TextView
33 | focusIdx int
34 | spinner *util.Spinner
35 | lastFocus tview.Primitive
36 |
37 | contract *service.Contract
38 | }
39 |
40 | func NewMethodCallDialog(app *App) *MethodCallDialog {
41 | mcd := &MethodCallDialog{
42 | app: app,
43 | spinner: util.NewSpinner(app.Application),
44 | }
45 |
46 | // setup layout
47 | mcd.initLayout()
48 |
49 | return mcd
50 | }
51 |
52 | func (d *MethodCallDialog) initLayout() {
53 | s := d.app.config.Style()
54 |
55 | // method list
56 | methods := tview.NewTable()
57 | methods.SetBorder(true)
58 | methods.SetBorderColor(s.DialogBorderColor)
59 | methods.SetTitle(style.Padding("Method"))
60 | methods.SetSelectable(true, false)
61 | methods.SetSelectionChangedFunc(func(row, column int) {
62 | d.showArguments()
63 | })
64 | methods.SetSelectedFunc(func(row, column int) {
65 | if d.methodHasNoArg() {
66 | d.callMethod()
67 | } else {
68 | d.focusNext()
69 | }
70 | })
71 | methods.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
72 | key := util.AsKey(event)
73 | switch key {
74 | case tcell.KeyTAB:
75 | d.focusNext()
76 | return nil
77 | default:
78 | return event
79 | }
80 | })
81 | d.methods = methods
82 |
83 | // arguments form
84 | args := tview.NewForm()
85 | args.SetBorder(true)
86 | args.SetBorderColor(s.DialogBorderColor)
87 | args.SetTitle(style.Padding("Arguments"))
88 | args.SetLabelColor(s.InputFieldLableColor)
89 | args.SetFieldBackgroundColor(s.InputFieldBgColor)
90 | args.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
91 | key := util.AsKey(event)
92 | switch key {
93 | case tcell.KeyEnter:
94 | if d.atLastFormItem() {
95 | d.callMethod()
96 | } else {
97 | d.focusNext()
98 | }
99 | return nil
100 | case tcell.KeyTAB:
101 | d.focusNext()
102 | return nil
103 | default:
104 | return event
105 | }
106 | })
107 | d.args = args
108 |
109 | top := tview.NewFlex().SetDirection(tview.FlexColumn)
110 | top.AddItem(methods, 0, 3, false)
111 | top.AddItem(args, 0, 7, true)
112 |
113 | // result panel
114 | result := tview.NewTextView()
115 | result.SetBorder(true)
116 | result.SetBorderColor(s.MethResultBorderColor)
117 | result.SetTitle(style.Padding("Result"))
118 | d.result = result
119 |
120 | whole := tview.NewFlex().SetDirection(tview.FlexRow)
121 | whole.AddItem(top, 0, 8, true)
122 | whole.AddItem(result, 0, 2, false)
123 | whole.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
124 | key := util.AsKey(event)
125 | if key == tcell.KeyEscape {
126 | d.Hide()
127 | return nil
128 | } else {
129 | return event
130 | }
131 | })
132 |
133 | d.Flex = whole
134 | }
135 |
136 | func (d *MethodCallDialog) SetContract(contract *service.Contract) {
137 | d.contract = contract
138 | d.refresh()
139 | }
140 |
141 | func (d *MethodCallDialog) Show() {
142 | if !d.display {
143 | // save last focused element
144 | d.lastFocus = d.app.GetFocus()
145 |
146 | d.Display(true)
147 | d.app.SetFocus(d)
148 | }
149 | }
150 |
151 | func (d *MethodCallDialog) Hide() {
152 | if d.display {
153 | d.Display(false)
154 | d.app.SetFocus(d.lastFocus)
155 | }
156 | }
157 |
158 | func (d *MethodCallDialog) Clear() {
159 | d.result.Clear()
160 | }
161 |
162 | func (d *MethodCallDialog) refresh() {
163 | d.methods.Clear()
164 | d.args.Clear(true)
165 | d.result.Clear()
166 | if d.contract.HasABI() {
167 | d.showMethodList()
168 | }
169 | }
170 |
171 | func (d *MethodCallDialog) showMethodList() {
172 | d.methods.Clear()
173 |
174 | s := d.app.config.Style()
175 | row := 0
176 | for _, method := range d.sortedMethods() {
177 | if method.IsConstant() {
178 | color := s.FgColor
179 | d.methods.SetCell(row, 0, tview.NewTableCell(method.Name).SetTextColor(color).SetExpansion(1))
180 | d.methods.SetCell(row, 1, tview.NewTableCell(" ").SetTextColor(color))
181 | } else {
182 | color := tcell.ColorDarkRed
183 | d.methods.SetCell(row, 0, tview.NewTableCell(method.Name).SetExpansion(1).SetBackgroundColor(color))
184 | d.methods.SetCell(row, 1, tview.NewTableCell("⚠").SetBackgroundColor(color))
185 | }
186 | row++
187 | }
188 | }
189 |
190 | // sortedMethods returns a list of method sorted by name and purity
191 | func (d *MethodCallDialog) sortedMethods() []abi.Method {
192 | methods := d.contract.GetABI().Methods
193 | sorted := make([]abi.Method, 0)
194 | for _, m := range methods {
195 | sorted = append(sorted, m)
196 | }
197 |
198 | sort.Slice(sorted, func(i, j int) bool {
199 | m1 := sorted[i]
200 | m2 := sorted[j]
201 |
202 | if m1.IsConstant() {
203 | if m2.IsConstant() {
204 | return m1.Name < m2.Name
205 | } else {
206 | return true
207 | }
208 | } else {
209 | if m2.IsConstant() {
210 | return false
211 | } else {
212 | return m1.Name < m2.Name
213 | }
214 | }
215 | })
216 |
217 | return sorted
218 | }
219 |
220 | func (d *MethodCallDialog) showArguments() {
221 | d.args.Clear(true)
222 |
223 | methodName := d.methodSelected()
224 | method := d.contract.GetABI().Methods[methodName]
225 | for _, arg := range method.Inputs {
226 | argName := arg.Name
227 | if argName == "" {
228 | argName = ""
229 | }
230 | d.args.AddInputField(argName, "", 999, nil, nil)
231 | }
232 | }
233 |
234 | func (d *MethodCallDialog) focusNext() {
235 | next := d.focusIdx + 1
236 | if next > d.args.GetFormItemCount() {
237 | next = 0
238 | }
239 |
240 | if next == 0 {
241 | d.app.SetFocus(d.methods)
242 | } else {
243 | formItem := d.args.GetFormItem(next - 1)
244 | d.app.SetFocus(formItem)
245 | }
246 |
247 | d.focusIdx = next
248 | }
249 |
250 | func (d *MethodCallDialog) atLastFormItem() bool {
251 | return d.focusIdx >= d.args.GetFormItemCount()
252 | }
253 |
254 | func (d *MethodCallDialog) methodSelected() string {
255 | row, _ := d.methods.GetSelection()
256 | return d.methods.GetCell(row, 0).Text
257 | }
258 |
259 | func (d *MethodCallDialog) methodHasNoArg() bool {
260 | methodName := d.methodSelected()
261 | method := d.contract.GetABI().Methods[methodName]
262 | return len(method.Inputs) == 0
263 | }
264 |
265 | func (d *MethodCallDialog) callMethod() {
266 | methodName := d.methodSelected()
267 | method := d.contract.GetABI().Methods[methodName]
268 |
269 | // unpack arguments
270 | args := make([]any, 0)
271 | for i := 0; i < d.args.GetFormItemCount(); i++ {
272 | item := d.args.GetFormItem(i).(*tview.InputField)
273 | arg := method.Inputs[i]
274 | val, err := conv.UnpackArgument(arg.Type, item.GetText())
275 | if err == nil {
276 | args = append(args, val)
277 | } else {
278 | log.Error("Cannot unpack argument", "argument", arg, "input", item.GetText(), "error", err)
279 | d.app.root.NotifyError(format.FineErrorMessage(
280 | "Input type for argument '%s' is incorrect, should be '%s'.", arg.Name, arg.Type.String(), err))
281 | return
282 | }
283 | }
284 |
285 | // ensure signer has signed in
286 | var signer *service.Signer
287 | if !method.IsConstant() {
288 | signer = d.app.root.signer.GetSigner()
289 | if signer == nil {
290 | d.app.root.NotifyError(format.FineErrorMessage("Cannot call a non-constant method without a signer. Please signin first."))
291 | return
292 | }
293 | }
294 |
295 | log.Info("Invoke contract method", "contract", d.contract.GetAddress(), "method", methodName, "args", args)
296 |
297 | // start spinner
298 | d.spinner.StartAndShow()
299 |
300 | go func() {
301 | var res []any
302 | var err error
303 |
304 | if method.IsConstant() {
305 | res, err = d.contract.Call(methodName, args...)
306 | } else {
307 | hash, e := d.contract.Send(signer, methodName, args...)
308 | res = []any{fmt.Sprintf("Transaction has been submitted to network.\n\nTxnHash: %s", hash)}
309 | err = e
310 | }
311 |
312 | d.app.QueueUpdateDraw(func() {
313 | if err != nil {
314 | d.spinner.StopAndHide() // must stop spinner before show error message
315 | log.Error("Method call is failed", "name", methodName, "args", args, "error", err)
316 | d.app.root.NotifyError(format.FineErrorMessage("Cannot call contract method '%s'.", methodName, err))
317 | } else {
318 | d.result.SetText(fmt.Sprint(res...))
319 | d.spinner.StopAndHide()
320 | }
321 | })
322 | }()
323 | }
324 |
325 | func (d *MethodCallDialog) Display(display bool) {
326 | d.display = display
327 | }
328 |
329 | func (d *MethodCallDialog) IsDisplay() bool {
330 | return d.display
331 | }
332 |
333 | // Focus implements tview.Focus
334 | func (d *MethodCallDialog) Focus(delegate func(p tview.Primitive)) {
335 | delegate(d.methods)
336 | }
337 |
338 | func (d *MethodCallDialog) SetCentral(x int, y int, width int, height int) {
339 | // self
340 | dialogWidth := width / 2
341 | dialogHeight := height / 2
342 | if dialogHeight < methodCallDialogMinHeight {
343 | dialogHeight = methodCallDialogMinHeight
344 | }
345 | if dialogWidth < methodCallDialogMinWidth {
346 | dialogWidth = methodCallDialogMinWidth
347 | }
348 | dialogX := x + ((width - dialogWidth) / 2)
349 | dialogY := y + ((height - dialogHeight) / 2)
350 | d.Flex.SetRect(dialogX, dialogY, dialogWidth, dialogHeight)
351 |
352 | // spinner
353 | d.spinner.SetCentral(d.result.GetInnerRect())
354 | }
355 |
356 | // Draw implements tview.Primitive
357 | func (d *MethodCallDialog) Draw(screen tcell.Screen) {
358 | if d.display {
359 | d.Flex.Draw(screen)
360 | }
361 | d.spinner.Draw(screen)
362 | }
363 |
--------------------------------------------------------------------------------
/internal/provider/ethereum.go:
--------------------------------------------------------------------------------
1 | package provider
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "math/big"
7 | "time"
8 |
9 | "github.com/dyng/ramen/internal/common"
10 | "github.com/dyng/ramen/internal/common/conv"
11 | "github.com/ethereum/go-ethereum"
12 | "github.com/ethereum/go-ethereum/accounts/abi"
13 | "github.com/ethereum/go-ethereum/common/hexutil"
14 | "github.com/ethereum/go-ethereum/core/types"
15 | "github.com/ethereum/go-ethereum/crypto"
16 | "github.com/ethereum/go-ethereum/ethclient"
17 | "github.com/ethereum/go-ethereum/log"
18 | "github.com/ethereum/go-ethereum/rpc"
19 | "github.com/pkg/errors"
20 | )
21 |
22 | var (
23 | ErrProviderNotSupport = errors.New("provider does not support this vendor-specific api")
24 | )
25 |
26 | const (
27 | // ProviderLocal represents a local node provider such as Geth, Hardhat etc.
28 | ProviderLocal string = "local"
29 | // ProviderAlchemy represents blockchain provider ProviderAlchemy (https://www.alchemy.com/)
30 | ProviderAlchemy = "alchemy"
31 |
32 | // DefaultTimeout is the default value for request timeout
33 | DefaultTimeout = 30 * time.Second
34 | )
35 |
36 | type rpcTransaction struct {
37 | tx *types.Transaction
38 | txExtraInfo
39 | }
40 |
41 | type txExtraInfo struct {
42 | BlockNumber *string `json:"blockNumber,omitempty"`
43 | BlockHash *common.Hash `json:"blockHash,omitempty"`
44 | From *common.Address `json:"from,omitempty"`
45 | Timestamp uint64 `json:"timeStamp,omitempty"`
46 | }
47 |
48 | func (tx *rpcTransaction) UnmarshalJSON(msg []byte) error {
49 | if err := json.Unmarshal(msg, &tx.tx); err != nil {
50 | return errors.WithStack(err)
51 | }
52 | return json.Unmarshal(msg, &tx.txExtraInfo)
53 | }
54 |
55 | func (tx *rpcTransaction) ToTransaction() common.Transaction {
56 | blockNumer, _ := conv.HexToInt(*tx.BlockNumber)
57 | return common.WrapTransaction(tx.tx, big.NewInt(blockNumer), tx.From, tx.Timestamp)
58 | }
59 |
60 | type rpcBlock struct {
61 | *types.Header
62 | rpcBlockBody
63 | }
64 |
65 | type rpcBlockBody struct {
66 | Hash common.Hash `json:"hash"`
67 | Transactions []rpcTransaction `json:"transactions"`
68 | UncleHashes []common.Hash `json:"uncles"`
69 | }
70 |
71 | func (b *rpcBlock) UnmarshalJSON(msg []byte) error {
72 | if err := json.Unmarshal(msg, &b.Header); err != nil {
73 | return errors.WithStack(err)
74 | }
75 | if err := json.Unmarshal(msg, &b.rpcBlockBody); err != nil {
76 | return errors.WithStack(err)
77 | }
78 | return nil
79 | }
80 |
81 | func (b *rpcBlock) ToBlock() *common.Block {
82 | txns := make(types.Transactions, len(b.Transactions))
83 | for i, tx := range b.Transactions {
84 | txns[i] = tx.tx
85 | }
86 | return types.NewBlockWithHeader(b.Header).WithBody(txns, []*types.Header{})
87 | }
88 |
89 | // forkchainSigner is a signer handles mixed transactions of different chains (often the case of local fork chain)
90 | type forkchainSigner struct {
91 | chainId *big.Int
92 | signers map[uint64]types.Signer
93 | }
94 |
95 | func newForkchainSigner(chainId *big.Int) types.Signer {
96 | return &forkchainSigner{
97 | chainId: chainId,
98 | signers: make(map[uint64]types.Signer),
99 | }
100 | }
101 |
102 | func (s forkchainSigner) getSigner(tx *types.Transaction) types.Signer {
103 | signer, ok := s.signers[tx.ChainId().Uint64()]
104 | if !ok {
105 | signer = types.NewLondonSigner(tx.ChainId())
106 | s.signers[tx.ChainId().Uint64()] = signer
107 | }
108 | return signer
109 | }
110 |
111 | // ChainID implements types.Signer
112 | func (s forkchainSigner) ChainID() *big.Int {
113 | return s.chainId
114 | }
115 |
116 | // Equal implements types.Signer
117 | func (s forkchainSigner) Equal(s2 types.Signer) bool {
118 | x, ok := s2.(forkchainSigner)
119 | return ok && x.chainId.Cmp(s.chainId) == 0
120 | }
121 |
122 | // Hash implements types.Signer
123 | func (s forkchainSigner) Hash(tx *types.Transaction) common.Hash {
124 | return s.getSigner(tx).Hash(tx)
125 | }
126 |
127 | // Sender implements types.Signer
128 | func (s forkchainSigner) Sender(tx *types.Transaction) (common.Address, error) {
129 | return s.getSigner(tx).Sender(tx)
130 | }
131 |
132 | // SignatureValues implements types.Signer
133 | func (s forkchainSigner) SignatureValues(tx *types.Transaction, sig []byte) (R *big.Int, S *big.Int, V *big.Int, err error) {
134 | return s.getSigner(tx).SignatureValues(tx, sig)
135 | }
136 |
137 | type Provider struct {
138 | url string
139 | providerType string
140 | client *ethclient.Client
141 | rpcClient *rpc.Client
142 |
143 | // cache
144 | chainId common.BigInt
145 | signer types.Signer
146 | }
147 |
148 | // NewProvider returns
149 | func NewProvider(url string, providerType string) *Provider {
150 | p := &Provider{
151 | url: url,
152 | providerType: providerType,
153 | }
154 |
155 | rpcClient, err := rpc.Dial(url)
156 | if err != nil {
157 | log.Error("Cannot connect to rpc server", "url", url, "error", errors.WithStack(err))
158 | common.Exit("Cannot connect to rpc server %s: %v", url, err)
159 | }
160 |
161 | p.rpcClient = rpcClient
162 | p.client = ethclient.NewClient(rpcClient)
163 |
164 | return p
165 | }
166 |
167 | func (p *Provider) GetType() string {
168 | return p.providerType
169 | }
170 |
171 | func (p *Provider) GetNetwork() (common.BigInt, error) {
172 | ctx, cancel := p.createContext()
173 | defer cancel()
174 |
175 | if p.chainId == nil {
176 | chainId, err := p.client.NetworkID(ctx)
177 | if err != nil {
178 | return nil, errors.WithStack(err)
179 | }
180 | p.chainId = chainId
181 | p.signer = newForkchainSigner(chainId)
182 | }
183 | return p.chainId, nil
184 | }
185 |
186 | func (p *Provider) GetGasPrice() (common.BigInt, error) {
187 | ctx, cancel := p.createContext()
188 | defer cancel()
189 | gasPrice, err := p.client.SuggestGasPrice(ctx)
190 | return gasPrice, errors.WithStack(err)
191 | }
192 |
193 | func (p *Provider) GetSigner() (types.Signer, error) {
194 | _, err := p.GetNetwork()
195 | if err != nil {
196 | return nil, err
197 | }
198 | return p.signer, nil
199 | }
200 |
201 | func (p *Provider) GetCode(addr common.Address) ([]byte, error) {
202 | ctx, cancel := p.createContext()
203 | defer cancel()
204 | code, err := p.client.CodeAt(ctx, addr, nil)
205 | return code, errors.WithStack(err)
206 | }
207 |
208 | func (p *Provider) GetBalance(addr common.Address) (common.BigInt, error) {
209 | ctx, cancel := p.createContext()
210 | defer cancel()
211 | balance, err := p.client.BalanceAt(ctx, addr, nil)
212 | return balance, errors.WithStack(err)
213 | }
214 |
215 | func (p *Provider) GetBlockHeight() (uint64, error) {
216 | ctx, cancel := p.createContext()
217 | defer cancel()
218 | height, err := p.client.BlockNumber(ctx)
219 | return height, errors.WithStack(err)
220 | }
221 |
222 | func (p *Provider) GetBlockByHash(hash common.Hash) (*common.Block, error) {
223 | ctx, cancel := p.createContext()
224 | defer cancel()
225 | block, err := p.client.BlockByHash(ctx, hash)
226 | return block, errors.WithStack(err)
227 | }
228 |
229 | func (p *Provider) GetBlockByNumber(number common.BigInt) (*common.Block, error) {
230 | ctx, cancel := p.createContext()
231 | defer cancel()
232 | block, err := p.client.BlockByNumber(ctx, number)
233 | return block, errors.WithStack(err)
234 | }
235 |
236 | func (p *Provider) BatchBlockByNumber(numberList []common.BigInt) ([]*common.Block, error) {
237 | size := len(numberList)
238 | rpcRes := make([]rpcBlock, size)
239 | reqs := make([]rpc.BatchElem, size)
240 | for i := range reqs {
241 | reqs[i] = rpc.BatchElem{
242 | Method: "eth_getBlockByNumber",
243 | Args: []any{toBlockNumArg(numberList[i]), true},
244 | Result: &rpcRes[i],
245 | }
246 | }
247 |
248 | ctx, cancel := p.createContext()
249 | defer cancel()
250 |
251 | err := p.rpcClient.BatchCallContext(ctx, reqs)
252 | if err != nil {
253 | return nil, errors.WithStack(err)
254 | }
255 |
256 | result := make([]*common.Block, size)
257 | for i := range result {
258 | result[i] = rpcRes[i].ToBlock()
259 | }
260 |
261 | // FIXME: individual request error handling
262 | return result, nil
263 | }
264 |
265 | func (p *Provider) BatchTransactionByHash(hashList []common.Hash) (common.Transactions, error) {
266 | size := len(hashList)
267 | rpcRes := make([]rpcTransaction, size)
268 | reqs := make([]rpc.BatchElem, size)
269 | for i := range reqs {
270 | reqs[i] = rpc.BatchElem{
271 | Method: "eth_getTransactionByHash",
272 | Args: []any{hashList[i]},
273 | Result: &rpcRes[i],
274 | }
275 | }
276 |
277 | ctx, cancel := p.createContext()
278 | defer cancel()
279 |
280 | err := p.rpcClient.BatchCallContext(ctx, reqs)
281 | if err != nil {
282 | return nil, errors.WithStack(err)
283 | }
284 |
285 | result := make(common.Transactions, size)
286 | for i := range result {
287 | result[i] = rpcRes[i].ToTransaction()
288 | }
289 |
290 | // FIXME: individual request error handling
291 | return result, nil
292 | }
293 |
294 | func (p *Provider) EstimateGas(address common.Address, from common.Address, input []byte) (uint64, error) {
295 | // build call message
296 | msg := ethereum.CallMsg{
297 | From: from,
298 | To: &address,
299 | Data: input,
300 | }
301 |
302 | ctx, cancel := p.createContext()
303 | defer cancel()
304 |
305 | gasLimit, err := p.client.EstimateGas(ctx, msg)
306 | if err != nil {
307 | return 0, errors.WithStack(err)
308 | }
309 |
310 | return gasLimit, nil
311 | }
312 |
313 | func (p *Provider) CallContract(address common.Address, abi *abi.ABI, method string, args ...any) ([]any, error) {
314 | // encode calldata
315 | input, err := abi.Pack(method, args...)
316 | if err != nil {
317 | return nil, errors.WithStack(err)
318 | }
319 |
320 | // build call message
321 | msg := ethereum.CallMsg{
322 | To: &address,
323 | Data: input,
324 | }
325 |
326 | ctx, cancel := p.createContext()
327 | defer cancel()
328 |
329 | data, err := p.client.CallContract(ctx, msg, nil)
330 | if err != nil {
331 | return nil, errors.WithStack(err)
332 | }
333 |
334 | vals, err := abi.Unpack(method, data)
335 | if err != nil {
336 | return nil, errors.WithStack(err)
337 | }
338 |
339 | return vals, nil
340 | }
341 |
342 | func (p *Provider) SendTransaction(txnReq *common.TxnRequest) (common.Hash, error) {
343 | ctx, cancel := p.createContext()
344 | defer cancel()
345 |
346 | key := txnReq.PrivateKey
347 | from := crypto.PubkeyToAddress(key.PublicKey)
348 |
349 | // fetch the next nonce
350 | nonce, err := p.client.PendingNonceAt(ctx, from)
351 | if err != nil {
352 | return common.Hash{}, errors.WithStack(err)
353 | }
354 |
355 | txn := types.NewTx(&types.LegacyTx{
356 | Nonce: nonce,
357 | GasPrice: txnReq.GasPrice,
358 | Gas: txnReq.GasLimit,
359 | To: txnReq.To,
360 | Value: txnReq.Value,
361 | Data: txnReq.Data,
362 | })
363 |
364 | signer, err := p.GetSigner()
365 | if err != nil {
366 | return common.Hash{}, err
367 | }
368 |
369 | signedTx, err := types.SignTx(txn, signer, key)
370 | if err != nil {
371 | return common.Hash{}, errors.WithStack(err)
372 | }
373 |
374 | err = p.client.SendTransaction(ctx, signedTx)
375 | return signedTx.Hash(), errors.WithStack(err)
376 | }
377 |
378 | func (p *Provider) SubscribeNewHead(ch chan<- *common.Header) (ethereum.Subscription, error) {
379 | ctx, cancel := p.createContext()
380 | defer cancel()
381 | sub, err := p.client.SubscribeNewHead(ctx, ch)
382 | return sub, errors.WithStack(err)
383 | }
384 |
385 | func (p *Provider) createContext() (context.Context, context.CancelFunc) {
386 | return context.WithTimeout(context.Background(), DefaultTimeout)
387 | }
388 |
389 | func toBlockNumArg(number *big.Int) string {
390 | if number == nil {
391 | return "latest"
392 | }
393 | pending := big.NewInt(-1)
394 | if number.Cmp(pending) == 0 {
395 | return "pending"
396 | }
397 | return hexutil.EncodeBig(number)
398 | }
399 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/VictoriaMetrics/fastcache v1.6.0 h1:C/3Oi3EiBCqufydp1neRZkqcwmEiuRT9c3fqvvgKm5o=
2 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
3 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
4 | github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k=
5 | github.com/btcsuite/btcd/btcec/v2 v2.2.0/go.mod h1:U7MHm051Al6XmscBQ0BoNydpOTsFAn707034b5nY8zU=
6 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
7 | github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
8 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
9 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13 | github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4=
14 | github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo=
15 | github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
16 | github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
17 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
18 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
19 | github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw=
20 | github.com/ethereum/go-ethereum v1.10.26 h1:i/7d9RBBwiXCEuyduBQzJw/mKmnvzsN14jqBmytw72s=
21 | github.com/ethereum/go-ethereum v1.10.26/go.mod h1:EYFyF19u3ezGLD4RqOkLq+ZCXzYbLoNDdZlMt7kyKFg=
22 | github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5 h1:FtmdgXiUlNeRsoNMFlKLDt+S+6hbjVMEW6RGQ7aUf7c=
23 | github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI=
24 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
25 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
26 | github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k=
27 | github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw=
28 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
29 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
30 | github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
31 | github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
32 | github.com/golang-jwt/jwt/v4 v4.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog=
33 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
34 | github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
35 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
36 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
37 | github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
38 | github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d h1:dg1dEPuWpEqDnvIw251EVy4zlP8gWbsGj4BsUKCRpYs=
39 | github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
40 | github.com/holiman/uint256 v1.2.0 h1:gpSYcPLWGv4sG43I2mVLiDZCNDh/EpGjSk8tmtxitHM=
41 | github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
42 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
43 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
44 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
45 | github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
46 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
47 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
48 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
49 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
50 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
51 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
52 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
53 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
54 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
55 | github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
56 | github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
57 | github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4UMlGaDa1UTA0sfKgvF0hQZzTYX8ktjiDc=
58 | github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
59 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
60 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
61 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
62 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
63 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
64 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
66 | github.com/prometheus/tsdb v0.7.1 h1:YZcsG11NqnK4czYLrWd9mpEuAJIHVQLwdrleYfszMAA=
67 | github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da h1:3Mh+tcC2KqetuHpWMurDeF+yOgyt4w4qtLIpwSQ3uqo=
68 | github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da/go.mod h1:lBUy/T5kyMudFzWUH/C2moN+NlU5qF505vzOyINXuUQ=
69 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
70 | github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
71 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
72 | github.com/rjeczalik/notify v0.9.1 h1:CLCKso/QK1snAlnhNR/CNvNiFU2saUtjV0bx3EwNeCE=
73 | github.com/rrivera/identicon v0.0.0-20180626043057-7875f45b0022 h1:Y0doVjX+IbXuOKDZR1ltCHd62HN/f8DP/8PsIyZcHq0=
74 | github.com/rrivera/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:2SnLnd4e0epVxpaMqngSWLtPjjFqS4UL2JdhNzfYSZA=
75 | github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
76 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
77 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
78 | github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
79 | github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
80 | github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
81 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
82 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
83 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
84 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
85 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
86 | github.com/status-im/keycard-go v0.0.0-20190316090335-8537d3370df4 h1:Gb2Tyox57NRNuZ2d3rmvB3pcmbu7O1RS3m8WRx7ilrg=
87 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
88 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
89 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
90 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
91 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
92 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
93 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
94 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
95 | github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM=
96 | github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI=
97 | github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms=
98 | github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4=
99 | github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef h1:wHSqTBrZW24CsNJDfeh9Ex6Pm0Rcpc7qrgKBiL44vF4=
100 | github.com/urfave/cli/v2 v2.10.2 h1:x3p8awjp/2arX+Nl/G2040AZpOCHS/eMJJ1/a+mye4Y=
101 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
102 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
103 | github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
104 | github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
105 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
106 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
107 | golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
108 | golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
109 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
110 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
111 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
112 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
113 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
114 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
115 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
116 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
117 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
118 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
119 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
120 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
121 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
122 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
123 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
124 | golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
125 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
126 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
127 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
128 | golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg=
129 | golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
130 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
131 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
132 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
133 | golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
134 | golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
135 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
136 | golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE=
137 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
138 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
139 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
140 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
141 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
142 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
143 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
144 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
145 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
146 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
147 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
148 |
--------------------------------------------------------------------------------