├── 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 | [![Go Report Card](https://goreportcard.com/badge/github.com/dyng/ramen)](https://goreportcard.com/report/github.com/dyng/ramen) 4 | [![Release](https://img.shields.io/github/v/release/dyng/ramen.svg)](https://github.com/derailed/k9s/releases) 5 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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 | --------------------------------------------------------------------------------