Skip to content

Commit

Permalink
add read-only mode to Seth
Browse files Browse the repository at this point in the history
  • Loading branch information
Tofel committed Sep 26, 2024
1 parent 773be7f commit 1809fd0
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 33 deletions.
23 changes: 23 additions & 0 deletions seth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Reliable and debug-friendly Ethereum client
14. [Single transaction tracing](#single-transaction-tracing)
15. [Bulk transaction tracing](#bulk-transaction-tracing)
16. [RPC traffic logging](#rpc-traffic-logging)
17. [Read-only mode](#read-only-mode)

## Goals

Expand Down Expand Up @@ -806,3 +807,25 @@ You need to pass a file with a list of transaction hashes to trace. The file sho

### RPC Traffic logging
With `SETH_LOG_LEVEL=trace` we will also log to console all traffic between Seth and RPC node. This can be useful for debugging as you can see all the requests and responses.


### Read-only mode
It's possible to use Seth in read-only mode only for transaction confirmation and tracing. Following operations will fail:
* contract deployment
* gas estimations (we need the pk/address to check nonce)
* RPC health check (we need a pk to send a transaction to ourselves)
* pending nonce protection (we need an address to check pending transactions)
* ephemeral keys (we need a pk to fund them)

The easiest way to enable read-only mode is to client via `ClientBuilder`:
```go
client, err := builder.
WithNetworkName("my network").
WithRpcUrl("ws://localhost:8546").
WithEphemeralAddresses(10, 1000).
WithPrivateKeys([]string{"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"}).
WithReadOnlyMode().
Build()
```

when builder is called with `WithReadOnlyMode()` it will disable all the operations mentioned above and all the configuration settings related to them.
114 changes: 93 additions & 21 deletions seth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const (
ErrCreateNonceManager = "failed to create nonce manager"
ErrCreateTracer = "failed to create tracer"
ErrReadContractMap = "failed to read deployed contract map"
ErrNoKeyLoaded = "failed to load private key"
ErrRpcHealthCheckFailed = "RPC health check failed ¯\\_(ツ)_/¯"
ErrContractDeploymentFailed = "contract deployment failed"

Expand Down Expand Up @@ -323,6 +322,9 @@ func NewClientRaw(
Msg("Created new client")

if cfg.ephemeral {
if len(c.Addresses) == 0 {
return nil, errors.New("no private keys loaded, cannot fund ephemeral addresses")
}
gasPrice, err := c.GetSuggestedLegacyFees(context.Background(), Priority_Standard)
if err != nil {
gasPrice = big.NewInt(c.Cfg.Network.GasPrice)
Expand Down Expand Up @@ -411,6 +413,10 @@ func (m *Client) checkRPCHealth() error {
gasPrice = big.NewInt(m.Cfg.Network.GasPrice)
}

if err := m.validateAddressesKeyNum(0); err != nil {
return err
}

err = m.TransferETHFromKey(ctx, 0, m.Addresses[0].Hex(), big.NewInt(10_000), gasPrice)
if err != nil {
return errors.Wrap(err, ErrRpcHealthCheckFailed)
Expand All @@ -421,8 +427,8 @@ func (m *Client) checkRPCHealth() error {
}

func (m *Client) TransferETHFromKey(ctx context.Context, fromKeyNum int, to string, value *big.Int, gasPrice *big.Int) error {
if fromKeyNum > len(m.PrivateKeys) || fromKeyNum > len(m.Addresses) {
return errors.Wrap(errors.New(ErrNoKeyLoaded), fmt.Sprintf("requested key: %d", fromKeyNum))
if err := m.validatePrivateKeysKeyNum(fromKeyNum); err != nil {
return err
}
toAddr := common.HexToAddress(to)
ctx, chainCancel := context.WithTimeout(ctx, m.Cfg.Network.TxnTimeout.Duration())
Expand Down Expand Up @@ -571,6 +577,9 @@ func WithBlockNumber(bn uint64) CallOpt {

// NewCallOpts returns a new sequential call options wrapper
func (m *Client) NewCallOpts(o ...CallOpt) *bind.CallOpts {
if errCallOpts := m.errCallOptsIfAddressCountTooLow(0); errCallOpts != nil {
return errCallOpts
}
co := &bind.CallOpts{
Pending: false,
From: m.Addresses[0],
Expand All @@ -583,6 +592,10 @@ func (m *Client) NewCallOpts(o ...CallOpt) *bind.CallOpts {

// NewCallKeyOpts returns a new sequential call options wrapper from the key N
func (m *Client) NewCallKeyOpts(keyNum int, o ...CallOpt) *bind.CallOpts {
if errCallOpts := m.errCallOptsIfAddressCountTooLow(keyNum); errCallOpts != nil {
return errCallOpts
}

co := &bind.CallOpts{
Pending: false,
From: m.Addresses[keyNum],
Expand All @@ -593,6 +606,52 @@ func (m *Client) NewCallKeyOpts(keyNum int, o ...CallOpt) *bind.CallOpts {
return co
}

// errCallOptsIfAddressCountTooLow returns non-nil CallOpts with error in Context if keyNum is out of range
func (m *Client) errCallOptsIfAddressCountTooLow(keyNum int) *bind.CallOpts {
if err := m.validateAddressesKeyNum(keyNum); err != nil {
errText := err.Error()
if keyNum == TimeoutKeyNum {
errText += " (this is a probably because we didn't manage to find any synced key before timeout)"
}

err := errors.New(errText)
m.Errors = append(m.Errors, err)
opts := &bind.CallOpts{}

// can't return nil, otherwise RPC wrapper will panic and we might lose funds on testnets/mainnets, that's why
// error is passed in Context here to avoid panic, whoever is using Seth should make sure that there is no error
// present in Context before using *bind.TransactOpts
opts.Context = context.WithValue(context.Background(), ContextErrorKey{}, err)

return opts
}

return nil
}

// errTxOptsIfPrivateKeysCountTooLow returns non-nil TransactOpts with error in Context if keyNum is out of range
func (m *Client) errTxOptsIfPrivateKeysCountTooLow(keyNum int) *bind.TransactOpts {
if err := m.validatePrivateKeysKeyNum(keyNum); err != nil {
errText := err.Error()
if keyNum == TimeoutKeyNum {
errText += " (this is a probably because we didn't manage to find any synced key before timeout)"
}

err := errors.New(errText)
m.Errors = append(m.Errors, err)
opts := &bind.TransactOpts{}

// can't return nil, otherwise RPC wrapper will panic and we might lose funds on testnets/mainnets, that's why
// error is passed in Context here to avoid panic, whoever is using Seth should make sure that there is no error
// present in Context before using *bind.TransactOpts
opts.Context = context.WithValue(context.Background(), ContextErrorKey{}, err)

return opts
}

return nil
}

// TransactOpt is a wrapper for bind.TransactOpts
type TransactOpt func(o *bind.TransactOpts)

Expand Down Expand Up @@ -680,23 +739,10 @@ func (m *Client) NewTXOpts(o ...TransactOpt) *bind.TransactOpts {
// NewTXKeyOpts returns a new transaction options wrapper,
// sets opts.GasPrice and opts.GasLimit from seth.toml or override with options
func (m *Client) NewTXKeyOpts(keyNum int, o ...TransactOpt) *bind.TransactOpts {
if keyNum > len(m.Addresses) || keyNum < 0 {
errText := fmt.Sprintf("keyNum is out of range. Expected %d-%d. Got: %d", 0, len(m.Addresses)-1, keyNum)
if keyNum == TimeoutKeyNum {
errText += " (this is a probably because, we didn't manage to find any synced key before timeout)"
}

err := errors.New(errText)
m.Errors = append(m.Errors, err)
opts := &bind.TransactOpts{}

// can't return nil, otherwise RPC wrapper will panic and we might lose funds on testnets/mainnets, that's why
// error is passed in Context here to avoid panic, whoever is using Seth should make sure that there is no error
// present in Context before using *bind.TransactOpts
opts.Context = context.WithValue(context.Background(), ContextErrorKey{}, err)

return opts
if errTxOpts := m.errTxOptsIfPrivateKeysCountTooLow(keyNum); errTxOpts != nil {
return errTxOpts
}

L.Debug().
Interface("KeyNum", keyNum).
Interface("Address", m.Addresses[keyNum]).
Expand Down Expand Up @@ -754,6 +800,10 @@ func (m *Client) getNonceStatus(address common.Address) (NonceStatus, error) {

// getProposedTransactionOptions gets all the tx info that network proposed
func (m *Client) getProposedTransactionOptions(keyNum int) (*bind.TransactOpts, NonceStatus, GasEstimations) {
if errTxOpts := m.errTxOptsIfPrivateKeysCountTooLow(keyNum); errTxOpts != nil {
return errTxOpts, NonceStatus{}, GasEstimations{}
}

nonceStatus, err := m.getNonceStatus(m.Addresses[keyNum])
if err != nil {
m.Errors = append(m.Errors, err)
Expand Down Expand Up @@ -1181,8 +1231,8 @@ func (m *Client) WaitUntilNoPendingTxForRootKey(timeout time.Duration) error {
// WaitUntilNoPendingTxForKeyNum waits until there's no pending transaction for key at index `keyNum`. If index is out of range or
// if after timeout there are still pending transactions, it returns error.
func (m *Client) WaitUntilNoPendingTxForKeyNum(keyNum int, timeout time.Duration) error {
if keyNum > len(m.Addresses)-1 || keyNum < 0 {
return fmt.Errorf("keyNum is out of range. Expected %d-%d. Got: %d", 0, len(m.Addresses)-1, keyNum)
if err := m.validateAddressesKeyNum(keyNum); err != nil {
return err
}
return m.WaitUntilNoPendingTx(m.Addresses[keyNum], timeout)
}
Expand Down Expand Up @@ -1217,6 +1267,28 @@ func (m *Client) WaitUntilNoPendingTx(address common.Address, timeout time.Durat
}
}

func (m *Client) validatePrivateKeysKeyNum(keyNum int) error {
if keyNum >= len(m.PrivateKeys) || keyNum < 0 {
if len(m.PrivateKeys) == 0 {
return fmt.Errorf("no private keys were loaded, but keyNum %d was requested", keyNum)
}
return fmt.Errorf("keyNum is out of range for known private keys. Expected %d to %d. Got: %d", 0, len(m.PrivateKeys)-1, keyNum)
}

return nil
}

func (m *Client) validateAddressesKeyNum(keyNum int) error {
if keyNum >= len(m.Addresses) || keyNum < 0 {
if len(m.Addresses) == 0 {
return fmt.Errorf("no addresses were loaded, but keyNum %d was requested", keyNum)
}
return fmt.Errorf("keyNum is out of range for known addresses. Expected %d to %d. Got: %d", 0, len(m.Addresses)-1, keyNum)
}

return nil
}

// mergeLogMeta add metadata from log
func (m *Client) mergeLogMeta(pe *DecodedTransactionLog, l types.Log) {
pe.Address = l.Address
Expand Down
53 changes: 51 additions & 2 deletions seth/client_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ import (
"github.com/pkg/errors"
)

const (
NoPkForRpcHealthCheckErr = "you need to provide at least one private key to check the RPC health"
NoPkForNonceProtection = "you need to provide at least one private key to enable nonce protection"
NoPkForEphemeralKeys = "you need to provide at least one private key to generate and fund ephemeral addresses"
NoPkForGasPriceEstimation = "you need to provide at least one private key to enable gas price estimations"
)

type ClientBuilder struct {
config *Config
errors []error
config *Config
readonly bool
errors []error
}

// NewClientBuilder creates a new ClientBuilder with reasonable default values. You only need to pass private key(s) and RPC URL to build a usable config.
Expand Down Expand Up @@ -351,6 +359,12 @@ func (c *ClientBuilder) WithNonceManager(rateLimitSec int, retries uint, timeout
return c
}

// WithReadOnlyMode sets the client to read-only mode. It removes all private keys from all Networks and disables nonce protection and ephemeral addresses.
func (c *ClientBuilder) WithReadOnlyMode() *ClientBuilder {
c.readonly = true
return c
}

// Build creates a new Client from the builder.
func (c *ClientBuilder) Build() (*Client, error) {
config, err := c.BuildConfig()
Expand All @@ -362,6 +376,8 @@ func (c *ClientBuilder) Build() (*Client, error) {

// BuildConfig returns the config from the builder.
func (c *ClientBuilder) BuildConfig() (*Config, error) {
c.handleReadOnlyMode()
c.validateConfig()
if len(c.errors) > 0 {
var concatenatedErrors string
for _, err := range c.errors {
Expand All @@ -372,6 +388,39 @@ func (c *ClientBuilder) BuildConfig() (*Config, error) {
return c.config, nil
}

func (c *ClientBuilder) handleReadOnlyMode() {
if c.readonly {
c.config.PendingNonceProtectionEnabled = false
c.config.CheckRpcHealthOnStart = false
c.config.EphemeralAddrs = nil
if c.config.Network != nil {
c.config.Network.GasPriceEstimationEnabled = false
c.config.Network.PrivateKeys = []string{}
}

for i := range c.config.Networks {
c.config.Networks[i].PrivateKeys = []string{}
}
}
}

func (c *ClientBuilder) validateConfig() {
if c.config.Network != nil {
if len(c.config.Network.PrivateKeys) == 0 && c.config.CheckRpcHealthOnStart {
c.errors = append(c.errors, errors.New(NoPkForRpcHealthCheckErr))
}
if len(c.config.Network.PrivateKeys) == 0 && c.config.PendingNonceProtectionEnabled {
c.errors = append(c.errors, errors.New(NoPkForNonceProtection))
}
if len(c.config.Network.PrivateKeys) == 0 && c.config.EphemeralAddrs != nil && *c.config.EphemeralAddrs > 0 {
c.errors = append(c.errors, errors.New(NoPkForEphemeralKeys))
}
if len(c.config.Network.PrivateKeys) == 0 && c.config.Network.GasPriceEstimationEnabled {
c.errors = append(c.errors, errors.New(NoPkForGasPriceEstimation))
}
}
}

func (c *ClientBuilder) checkIfNetworkIsSet() bool {
if c.config.Network == nil {
c.errors = append(c.errors, errors.New("at least one method that required network to be set was called, but network is nil"))
Expand Down
17 changes: 8 additions & 9 deletions seth/client_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,42 @@ package seth

import (
"crypto/ecdsa"
"errors"

"github.com/ethereum/go-ethereum/common"
)

// MustGetRootKeyAddress returns the root key address from the client configuration. If no addresses are found, it panics.
// Root key address is the first address in the list of addresses.
func (m *Client) MustGetRootKeyAddress() common.Address {
if len(m.Addresses) == 0 {
panic("no addresses found in the client configuration")
if err := m.validateAddressesKeyNum(0); err != nil {
panic(err)
}
return m.Addresses[0]
}

// GetRootKeyAddress returns the root key address from the client configuration. If no addresses are found, it returns an error.
// Root key address is the first address in the list of addresses.
func (m *Client) GetRootKeyAddress() (common.Address, error) {
if len(m.Addresses) == 0 {
return common.Address{}, errors.New("no addresses found in the client configuration")
if err := m.validateAddressesKeyNum(0); err != nil {
return common.Address{}, err
}
return m.Addresses[0], nil
}

// MustGetRootPrivateKey returns the private key of root key/address from the client configuration. If no private keys are found, it panics.
// Root private key is the first private key in the list of private keys.
func (m *Client) MustGetRootPrivateKey() *ecdsa.PrivateKey {
if len(m.PrivateKeys) == 0 {
panic("no private keys found in the client configuration")
if err := m.validatePrivateKeysKeyNum(0); err != nil {
panic(err)
}
return m.PrivateKeys[0]
}

// GetRootPrivateKey returns the private key of root key/address from the client configuration. If no private keys are found, it returns an error.
// Root private key is the first private key in the list of private keys.
func (m *Client) GetRootPrivateKey() (*ecdsa.PrivateKey, error) {
if len(m.PrivateKeys) == 0 {
return nil, errors.New("no private keys found in the client configuration")
if err := m.validatePrivateKeysKeyNum(0); err != nil {
return nil, err
}
return m.PrivateKeys[0], nil
}
Loading

0 comments on commit 1809fd0

Please sign in to comment.