diff --git a/accounts/account.go b/accounts/account.go new file mode 100644 index 00000000..85304136 --- /dev/null +++ b/accounts/account.go @@ -0,0 +1,145 @@ +package accounts + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "os" + + account "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/unpackdev/solgo/clients" +) + +// Account represents an Ethereum account with extended functionalities. +// It embeds ClientPool for network interactions and KeyStore for account management. +// It also includes fields for account details, network information, and additional tags. +type Account struct { + *clients.ClientPool `json:"-" yaml:"-"` // ClientPool for Ethereum client interactions + *keystore.KeyStore `json:"-" yaml:"-"` // KeyStore for managing account keys + KeystoreAccount account.Account `json:"account" yaml:"account"` // Ethereum account information + Password string `json:"password" yaml:"password"` // Account's password + Network Network `json:"network" yaml:"network"` // Network information + Tags []string `json:"tags" yaml:"tags"` // Arbitrary tags for the account +} + +// HasTag checks if the account has a specific tag. +// Returns true if the tag exists, false otherwise. +func (a *Account) HasTag(tag string) bool { + for _, t := range a.Tags { + if t == tag { + return true + } + } + + return false +} + +// DecodePassword decodes the base64-encoded password. +// Returns the decoded password or an error if decoding fails. +func (a *Account) DecodePassword() (string, error) { + passwd, err := base64.StdEncoding.DecodeString(a.Password) + return string(passwd), err +} + +// GetAddress retrieves the Ethereum address of the account. +func (a *Account) GetAddress() common.Address { + return a.KeystoreAccount.Address +} + +// Balance queries the Ethereum network for the account's balance at a specific block number. +// Returns the balance or an error if the query fails. +func (a *Account) Balance(ctx context.Context, blockNum *big.Int) (*big.Int, error) { + client := a.ClientPool.GetClientByGroup(string(a.Network)) + if client == nil { + return big.NewInt(0), fmt.Errorf("no client found for network %s", string(a.Network)) + } + + balance, err := client.BalanceAt(ctx, a.KeystoreAccount.Address, blockNum) + if err != nil { + return big.NewInt(0), err + } + + return balance, nil +} + +// Transfer handles the transfer of Ethereum from the account to another address. +// Validates sufficient balance and signs the transaction with the account's passphrase. +// Returns the signed transaction or an error if the transfer fails. +func (a *Account) Transfer(ctx context.Context, to common.Address, value *big.Int) (*types.Transaction, error) { + client := a.ClientPool.GetClientByGroup(string(a.Network)) + if client == nil { + return nil, fmt.Errorf("no client found for network %s", string(a.Network)) + } + + currentBalance, err := a.Balance(ctx, nil) + if err != nil { + return nil, err + } + + if currentBalance.Cmp(value) < 0 { + return nil, fmt.Errorf("insufficient balance") + } + + passwd, err := a.DecodePassword() + if err != nil { + return nil, fmt.Errorf("failed to decode password: %s", err.Error()) + } + + nonce, err := client.PendingNonceAt(context.Background(), a.KeystoreAccount.Address) + if err != nil { + return nil, err + } + + gasLimit := uint64(21000) + gasPrice, err := client.SuggestGasPrice(context.Background()) + if err != nil { + return nil, err + } + + var data []byte + tx := types.NewTransaction(nonce, to, value, gasLimit, gasPrice, data) + + signedTx, err := a.KeyStore.SignTxWithPassphrase(a.KeystoreAccount, passwd, tx, big.NewInt(client.GetNetworkID())) + if err != nil { + return nil, err + } + + if err := client.SendTransaction(ctx, signedTx); err != nil { + return nil, err + } + + return signedTx, nil +} + +// SaveToPath saves the account information to a specified file path in JSON format. +// Returns an error if the saving process fails. +func (a *Account) SaveToPath(path string) error { + file, err := json.MarshalIndent(a, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, file, 0644) +} + +// LoadAccount is a utility function to load an account from a JSON file at a given path. +// Returns the loaded Account or an error if loading fails. +func LoadAccount(path string) (*Account, error) { + file, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var account Account + err = json.Unmarshal(file, &account) + if err != nil { + return nil, err + } + + return &account, nil +} diff --git a/accounts/doc.go b/accounts/doc.go new file mode 100644 index 00000000..342ffc50 --- /dev/null +++ b/accounts/doc.go @@ -0,0 +1,8 @@ +// Package accounts provides a comprehensive suite of tools and types for managing Ethereum and Ethereum-compatible +// blockchain accounts. It facilitates various operations such as account creation, importing, listing, and retrieval, +// alongside managing their corresponding keystore files for different networks. + +// The package is designed to be flexible and adaptable to various Ethereum-compatible networks like Ethereum mainnet, +// Binance Smart Chain (BSC), and Polygon. It provides a structured approach to handle accounts across these networks, +// making it easier for developers to interact with different blockchain environments through a unified interface. +package accounts diff --git a/accounts/manager.go b/accounts/manager.go new file mode 100644 index 00000000..763093f1 --- /dev/null +++ b/accounts/manager.go @@ -0,0 +1,191 @@ +package accounts + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path" + "strings" + + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/unpackdev/solgo/clients" + "github.com/unpackdev/solgo/utils" +) + +// Manager handles the account operations across various Ethereum networks. +// It maintains a map of keystores and accounts for each supported network. +type Manager struct { + ctx context.Context // Context for managing async operations + cfg *Options // Configuration options for the Manager + client *clients.ClientPool // Ethereum client pool + ks map[Network]*keystore.KeyStore // Keystores for different networks + accounts map[Network][]*Account // Accounts mapped by their network +} + +// NewManager initializes a new Manager instance. +// It checks for the existence of keystore paths and supported networks, +// and loads existing accounts from the keystore. +func NewManager(ctx context.Context, client *clients.ClientPool, cfg *Options) (*Manager, error) { + if !utils.PathExists(cfg.KeystorePath) { + return nil, fmt.Errorf("keystore path does not exist: %s", cfg.KeystorePath) + } + + if len(cfg.SupportedNetworks) == 0 { + return nil, fmt.Errorf("no supported networks provided. You must provide at least one network") + } + + var keystores = make(map[Network]*keystore.KeyStore) + + // Now for each supported network, we need to create a subdirectory in the keystore path if it does not exist. + // Be sure that write permissions are set correctly. + for _, network := range cfg.SupportedNetworks { + networkPath := path.Join(cfg.KeystorePath, strings.ToLower(string(network))) + if !utils.PathExists(networkPath) { + if err := os.MkdirAll(networkPath, 0700); err != nil { + return nil, err + } + } + + keystores[network] = keystore.NewKeyStore( + networkPath, + keystore.StandardScryptN, + keystore.StandardScryptP, + ) + } + + toReturn := &Manager{ + ctx: ctx, + cfg: cfg, + ks: keystores, + client: client, + accounts: make(map[Network][]*Account), + } + + if err := toReturn.Load(); err != nil { + return nil, err + } + + return toReturn, nil +} + +// Load loads accounts from the keystore for each network. +// Returns an error if it fails to load accounts for any network. +func (m *Manager) Load() error { + for _, network := range m.cfg.SupportedNetworks { + ks, err := m.GetKeystore(network) + if err != nil { + return err + } + + for _, kAcc := range ks.Accounts() { + path := path.Join(m.GetNetworkPath(network), kAcc.Address.Hex()+".json") + acc, err := LoadAccount(path) + if err != nil { + return err + } + acc.ClientPool = m.client + acc.KeyStore = ks + m.accounts[network] = append(m.accounts[network], acc) + } + } + + return nil +} + +// GetConfig returns the configuration options of the Manager. +func (m *Manager) GetConfig() *Options { + return m.cfg +} + +// GetNetworkPath returns the file path for a given network's keystore. +func (m *Manager) GetNetworkPath(network Network) string { + return path.Join(m.cfg.KeystorePath, strings.ToLower(string(network))) +} + +// GetKeystore retrieves the keystore for a given network. +// Returns an error if the network is not supported. +func (m *Manager) GetKeystore(network Network) (*keystore.KeyStore, error) { + if _, ok := m.ks[network]; !ok { + return nil, fmt.Errorf("network %s is not supported", network) + } + + return m.ks[network], nil +} + +// Import imports an account with a given private key and password into the keystore of a specified network. +// TODO: Currently, the function body is empty and needs implementation. +func (m *Manager) Import(network Network, privateKey string, password string) error { + return nil +} + +// Create creates a new account for a given network with a specified password and optional tags. +// It saves the account to the network's keystore path and adds it to the accounts map. +func (m *Manager) Create(network Network, password string, tags ...string) (*Account, error) { + ks, err := m.GetKeystore(network) + if err != nil { + return nil, err + } + + kacc, err := ks.NewAccount(password) + if err != nil { + return nil, err + } + + acc := &Account{ + ClientPool: m.client, + KeyStore: ks, + KeystoreAccount: kacc, + Password: base64.StdEncoding.EncodeToString([]byte(password)), + Network: network, + Tags: tags, + } + + // Now we need to save the account to the keystore path. + path := path.Join(m.GetNetworkPath(network), kacc.Address.Hex()+".json") + + if err := acc.SaveToPath(path); err != nil { + return nil, err + } + + // Now we need to add the account to the accounts map. + m.accounts[network] = append(m.accounts[network], acc) + + return acc, nil +} + +// List lists all accounts for a given network, optionally filtered by tags. +func (m *Manager) List(network Network, tags ...string) []*Account { + var toReturn []*Account + + if accounts, ok := m.accounts[network]; ok { + if len(tags) == 0 { + return accounts + } + + for _, acc := range accounts { + for _, tag := range tags { + if acc.HasTag(tag) { + toReturn = append(toReturn, acc) + } + } + } + } + + return toReturn +} + +// Get retrieves a specific account by its address for a given network. +// Returns an error if the account is not found. +func (m *Manager) Get(network Network, address common.Address) (*Account, error) { + if accounts, ok := m.accounts[network]; ok { + for _, acc := range accounts { + if acc.KeystoreAccount.Address.Hex() == address.Hex() { + return acc, nil + } + } + } + + return nil, fmt.Errorf("account not found") +} diff --git a/accounts/options.go b/accounts/options.go new file mode 100644 index 00000000..99e31ae2 --- /dev/null +++ b/accounts/options.go @@ -0,0 +1,12 @@ +package accounts + +// Options defines the configuration parameters for account management. +type Options struct { + // KeystorePath specifies the file system path to the directory where the keystore files are stored. + // The keystore is used to securely store the private keys of Ethereum accounts. + KeystorePath string `json:"keystore_path" yaml:"keystore_path"` + + // SupportedNetworks lists the Ethereum based networks that the account manager will interact with. + // Each network has a corresponding keystore and set of account configurations. + SupportedNetworks []Network `json:"supported_networks" yaml:"supported_networks"` +} diff --git a/accounts/types.go b/accounts/types.go new file mode 100644 index 00000000..6e999488 --- /dev/null +++ b/accounts/types.go @@ -0,0 +1,16 @@ +package accounts + +// Network defines a type for representing various Ethereum-compatible networks. +// It is used to specify and differentiate between different blockchain networks. +type Network string + +const ( + // Ethereum represents the Ethereum mainnet. + Ethereum Network = "ethereum" + + // Bsc represents the Binance Smart Chain network. + Bsc Network = "bsc" + + // Polygon represents the Polygon (formerly Matic Network) network. + Polygon Network = "polygon" +) diff --git a/go.mod b/go.mod index 60aa1e69..28c40d75 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.0.2 // indirect github.com/ethereum/c-kzg-4844 v0.3.1 // indirect github.com/fogleman/gg v1.3.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect diff --git a/go.sum b/go.sum index f858f53d..4c8ebfaf 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,7 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764= @@ -59,6 +60,7 @@ github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5 h1:FtmdgXiUlNeRsoNMFlK github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= @@ -245,6 +247,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/utils/path.go b/utils/path.go index c4c6fe45..ad607e1c 100644 --- a/utils/path.go +++ b/utils/path.go @@ -1,9 +1,28 @@ package utils -import "path/filepath" +import ( + "os" + "path/filepath" +) // GetLocalSourcesPath returns the absolute path to the local sources directory. func GetLocalSourcesPath() string { absPath, _ := filepath.Abs(filepath.Clean("../sources/")) return absPath } + +// PathExists returns true if the given path exists. +func PathExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} + +// GetCurrentPath returns the current working directory. +func GetCurrentPath() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + return dir, nil +} diff --git a/utils/wei.go b/utils/wei.go new file mode 100644 index 00000000..035ec474 --- /dev/null +++ b/utils/wei.go @@ -0,0 +1,53 @@ +package utils + +import ( + "fmt" + "math/big" +) + +var Ether = big.NewInt(1e18) +var GWei = big.NewInt(1e9) + +// FromWei converts a balance in wei to Ether. +func FromWei(wei *big.Int, unit *big.Int) *big.Float { + if wei == nil { + return big.NewFloat(0) + } + + // Convert wei to a big.Float for division. + weiFloat := new(big.Float).SetInt(wei) + + eUnit := Ether + + if unit == nil { + eUnit = Ether + } + + // Divide by 1e18 to convert wei to Ether. + ether := new(big.Float).Quo(weiFloat, new(big.Float).SetInt(eUnit)) + + return ether +} + +// ToWei converts an Ether value (as a decimal) to Wei. +func ToWei(etherValueStr string, unit *big.Int) (*big.Int, error) { + etherValue, ok := new(big.Float).SetString(etherValueStr) + if !ok { + return nil, fmt.Errorf("invalid ether value: %s", etherValueStr) + } + + eUnit := Ether + + if unit == nil { + eUnit = Ether + } + + // Multiply the Ether value by 1e18 to convert it to Wei. + wei := new(big.Float).Mul(etherValue, new(big.Float).SetInt(eUnit)) + + // Convert the result to *big.Int. + result := new(big.Int) + wei.Int(result) // Note: This truncates the fractional part. + + return result, nil +}