Skip to content

Commit 551e0ad

Browse files
authored
[TT-1761] add read-only mode to Seth (#1195)
1 parent 0f95c7e commit 551e0ad

File tree

8 files changed

+348
-32
lines changed

8 files changed

+348
-32
lines changed

seth/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Reliable and debug-friendly Ethereum client
3535
14. [Single transaction tracing](#single-transaction-tracing)
3636
15. [Bulk transaction tracing](#bulk-transaction-tracing)
3737
16. [RPC traffic logging](#rpc-traffic-logging)
38+
17. [Read-only mode](#read-only-mode)
3839

3940
## Goals
4041

@@ -808,3 +809,25 @@ You need to pass a file with a list of transaction hashes to trace. The file sho
808809

809810
### RPC Traffic logging
810811
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.
812+
813+
814+
### Read-only mode
815+
It's possible to use Seth in read-only mode only for transaction confirmation and tracing. Following operations will fail:
816+
* contract deployment
817+
* gas estimations (we need the pk/address to check nonce)
818+
* RPC health check (we need a pk to send a transaction to ourselves)
819+
* pending nonce protection (we need an address to check pending transactions)
820+
* ephemeral keys (we need a pk to fund them)
821+
822+
The easiest way to enable read-only mode is to client via `ClientBuilder`:
823+
```go
824+
client, err := builder.
825+
WithNetworkName("my network").
826+
WithRpcUrl("ws://localhost:8546").
827+
WithEphemeralAddresses(10, 1000).
828+
WithPrivateKeys([]string{"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"}).
829+
WithReadOnlyMode().
830+
Build()
831+
```
832+
833+
when builder is called with `WithReadOnlyMode()` it will disable all the operations mentioned above and all the configuration settings related to them.

seth/client.go

Lines changed: 93 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ func NewClientRaw(
323323
Msg("Created new client")
324324

325325
if cfg.ephemeral {
326+
if len(c.Addresses) == 0 {
327+
return nil, errors.New("no private keys loaded, cannot fund ephemeral addresses")
328+
}
326329
gasPrice, err := c.GetSuggestedLegacyFees(context.Background(), Priority_Standard)
327330
if err != nil {
328331
gasPrice = big.NewInt(c.Cfg.Network.GasPrice)
@@ -411,6 +414,10 @@ func (m *Client) checkRPCHealth() error {
411414
gasPrice = big.NewInt(m.Cfg.Network.GasPrice)
412415
}
413416

417+
if err := m.validateAddressesKeyNum(0); err != nil {
418+
return err
419+
}
420+
414421
err = m.TransferETHFromKey(ctx, 0, m.Addresses[0].Hex(), big.NewInt(10_000), gasPrice)
415422
if err != nil {
416423
return errors.Wrap(err, ErrRpcHealthCheckFailed)
@@ -421,8 +428,8 @@ func (m *Client) checkRPCHealth() error {
421428
}
422429

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

572579
// NewCallOpts returns a new sequential call options wrapper
573580
func (m *Client) NewCallOpts(o ...CallOpt) *bind.CallOpts {
581+
if errCallOpts := m.errCallOptsIfAddressCountTooLow(0); errCallOpts != nil {
582+
return errCallOpts
583+
}
574584
co := &bind.CallOpts{
575585
Pending: false,
576586
From: m.Addresses[0],
@@ -583,6 +593,10 @@ func (m *Client) NewCallOpts(o ...CallOpt) *bind.CallOpts {
583593

584594
// NewCallKeyOpts returns a new sequential call options wrapper from the key N
585595
func (m *Client) NewCallKeyOpts(keyNum int, o ...CallOpt) *bind.CallOpts {
596+
if errCallOpts := m.errCallOptsIfAddressCountTooLow(keyNum); errCallOpts != nil {
597+
return errCallOpts
598+
}
599+
586600
co := &bind.CallOpts{
587601
Pending: false,
588602
From: m.Addresses[keyNum],
@@ -593,6 +607,52 @@ func (m *Client) NewCallKeyOpts(keyNum int, o ...CallOpt) *bind.CallOpts {
593607
return co
594608
}
595609

610+
// errCallOptsIfAddressCountTooLow returns non-nil CallOpts with error in Context if keyNum is out of range
611+
func (m *Client) errCallOptsIfAddressCountTooLow(keyNum int) *bind.CallOpts {
612+
if err := m.validateAddressesKeyNum(keyNum); err != nil {
613+
errText := err.Error()
614+
if keyNum == TimeoutKeyNum {
615+
errText += " (this is a probably because we didn't manage to find any synced key before timeout)"
616+
}
617+
618+
err := errors.New(errText)
619+
m.Errors = append(m.Errors, err)
620+
opts := &bind.CallOpts{}
621+
622+
// can't return nil, otherwise RPC wrapper will panic and we might lose funds on testnets/mainnets, that's why
623+
// error is passed in Context here to avoid panic, whoever is using Seth should make sure that there is no error
624+
// present in Context before using *bind.TransactOpts
625+
opts.Context = context.WithValue(context.Background(), ContextErrorKey{}, err)
626+
627+
return opts
628+
}
629+
630+
return nil
631+
}
632+
633+
// errTxOptsIfPrivateKeysCountTooLow returns non-nil TransactOpts with error in Context if keyNum is out of range
634+
func (m *Client) errTxOptsIfPrivateKeysCountTooLow(keyNum int) *bind.TransactOpts {
635+
if err := m.validatePrivateKeysKeyNum(keyNum); err != nil {
636+
errText := err.Error()
637+
if keyNum == TimeoutKeyNum {
638+
errText += " (this is a probably because we didn't manage to find any synced key before timeout)"
639+
}
640+
641+
err := errors.New(errText)
642+
m.Errors = append(m.Errors, err)
643+
opts := &bind.TransactOpts{}
644+
645+
// can't return nil, otherwise RPC wrapper will panic and we might lose funds on testnets/mainnets, that's why
646+
// error is passed in Context here to avoid panic, whoever is using Seth should make sure that there is no error
647+
// present in Context before using *bind.TransactOpts
648+
opts.Context = context.WithValue(context.Background(), ContextErrorKey{}, err)
649+
650+
return opts
651+
}
652+
653+
return nil
654+
}
655+
596656
// TransactOpt is a wrapper for bind.TransactOpts
597657
type TransactOpt func(o *bind.TransactOpts)
598658

@@ -680,23 +740,10 @@ func (m *Client) NewTXOpts(o ...TransactOpt) *bind.TransactOpts {
680740
// NewTXKeyOpts returns a new transaction options wrapper,
681741
// sets opts.GasPrice and opts.GasLimit from seth.toml or override with options
682742
func (m *Client) NewTXKeyOpts(keyNum int, o ...TransactOpt) *bind.TransactOpts {
683-
if keyNum > len(m.Addresses) || keyNum < 0 {
684-
errText := fmt.Sprintf("keyNum is out of range. Expected %d-%d. Got: %d", 0, len(m.Addresses)-1, keyNum)
685-
if keyNum == TimeoutKeyNum {
686-
errText += " (this is a probably because, we didn't manage to find any synced key before timeout)"
687-
}
688-
689-
err := errors.New(errText)
690-
m.Errors = append(m.Errors, err)
691-
opts := &bind.TransactOpts{}
692-
693-
// can't return nil, otherwise RPC wrapper will panic and we might lose funds on testnets/mainnets, that's why
694-
// error is passed in Context here to avoid panic, whoever is using Seth should make sure that there is no error
695-
// present in Context before using *bind.TransactOpts
696-
opts.Context = context.WithValue(context.Background(), ContextErrorKey{}, err)
697-
698-
return opts
743+
if errTxOpts := m.errTxOptsIfPrivateKeysCountTooLow(keyNum); errTxOpts != nil {
744+
return errTxOpts
699745
}
746+
700747
L.Debug().
701748
Interface("KeyNum", keyNum).
702749
Interface("Address", m.Addresses[keyNum]).
@@ -754,6 +801,10 @@ func (m *Client) getNonceStatus(address common.Address) (NonceStatus, error) {
754801

755802
// getProposedTransactionOptions gets all the tx info that network proposed
756803
func (m *Client) getProposedTransactionOptions(keyNum int) (*bind.TransactOpts, NonceStatus, GasEstimations) {
804+
if errTxOpts := m.errTxOptsIfPrivateKeysCountTooLow(keyNum); errTxOpts != nil {
805+
return errTxOpts, NonceStatus{}, GasEstimations{}
806+
}
807+
757808
nonceStatus, err := m.getNonceStatus(m.Addresses[keyNum])
758809
if err != nil {
759810
m.Errors = append(m.Errors, err)
@@ -1181,8 +1232,8 @@ func (m *Client) WaitUntilNoPendingTxForRootKey(timeout time.Duration) error {
11811232
// WaitUntilNoPendingTxForKeyNum waits until there's no pending transaction for key at index `keyNum`. If index is out of range or
11821233
// if after timeout there are still pending transactions, it returns error.
11831234
func (m *Client) WaitUntilNoPendingTxForKeyNum(keyNum int, timeout time.Duration) error {
1184-
if keyNum > len(m.Addresses)-1 || keyNum < 0 {
1185-
return fmt.Errorf("keyNum is out of range. Expected %d-%d. Got: %d", 0, len(m.Addresses)-1, keyNum)
1235+
if err := m.validateAddressesKeyNum(keyNum); err != nil {
1236+
return err
11861237
}
11871238
return m.WaitUntilNoPendingTx(m.Addresses[keyNum], timeout)
11881239
}
@@ -1217,6 +1268,28 @@ func (m *Client) WaitUntilNoPendingTx(address common.Address, timeout time.Durat
12171268
}
12181269
}
12191270

1271+
func (m *Client) validatePrivateKeysKeyNum(keyNum int) error {
1272+
if keyNum >= len(m.PrivateKeys) || keyNum < 0 {
1273+
if len(m.PrivateKeys) == 0 {
1274+
return fmt.Errorf("no private keys were loaded, but keyNum %d was requested", keyNum)
1275+
}
1276+
return fmt.Errorf("keyNum is out of range for known private keys. Expected %d to %d. Got: %d", 0, len(m.PrivateKeys)-1, keyNum)
1277+
}
1278+
1279+
return nil
1280+
}
1281+
1282+
func (m *Client) validateAddressesKeyNum(keyNum int) error {
1283+
if keyNum >= len(m.Addresses) || keyNum < 0 {
1284+
if len(m.Addresses) == 0 {
1285+
return fmt.Errorf("no addresses were loaded, but keyNum %d was requested", keyNum)
1286+
}
1287+
return fmt.Errorf("keyNum is out of range for known addresses. Expected %d to %d. Got: %d", 0, len(m.Addresses)-1, keyNum)
1288+
}
1289+
1290+
return nil
1291+
}
1292+
12201293
// mergeLogMeta add metadata from log
12211294
func (m *Client) mergeLogMeta(pe *DecodedTransactionLog, l types.Log) {
12221295
pe.Address = l.Address

seth/client_builder.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@ import (
77
"github.com/pkg/errors"
88
)
99

10+
const (
11+
NoPkForRpcHealthCheckErr = "you need to provide at least one private key to check the RPC health"
12+
NoPkForNonceProtection = "you need to provide at least one private key to enable nonce protection"
13+
NoPkForEphemeralKeys = "you need to provide at least one private key to generate and fund ephemeral addresses"
14+
NoPkForGasPriceEstimation = "you need to provide at least one private key to enable gas price estimations"
15+
)
16+
1017
type ClientBuilder struct {
11-
config *Config
12-
errors []error
18+
config *Config
19+
readonly bool
20+
errors []error
1321
}
1422

1523
// 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.
@@ -351,6 +359,12 @@ func (c *ClientBuilder) WithNonceManager(rateLimitSec int, retries uint, timeout
351359
return c
352360
}
353361

362+
// WithReadOnlyMode sets the client to read-only mode. It removes all private keys from all Networks and disables nonce protection and ephemeral addresses.
363+
func (c *ClientBuilder) WithReadOnlyMode() *ClientBuilder {
364+
c.readonly = true
365+
return c
366+
}
367+
354368
// Build creates a new Client from the builder.
355369
func (c *ClientBuilder) Build() (*Client, error) {
356370
config, err := c.BuildConfig()
@@ -362,6 +376,8 @@ func (c *ClientBuilder) Build() (*Client, error) {
362376

363377
// BuildConfig returns the config from the builder.
364378
func (c *ClientBuilder) BuildConfig() (*Config, error) {
379+
c.handleReadOnlyMode()
380+
c.validateConfig()
365381
if len(c.errors) > 0 {
366382
var concatenatedErrors string
367383
for _, err := range c.errors {
@@ -372,6 +388,39 @@ func (c *ClientBuilder) BuildConfig() (*Config, error) {
372388
return c.config, nil
373389
}
374390

391+
func (c *ClientBuilder) handleReadOnlyMode() {
392+
if c.readonly {
393+
c.config.PendingNonceProtectionEnabled = false
394+
c.config.CheckRpcHealthOnStart = false
395+
c.config.EphemeralAddrs = nil
396+
if c.config.Network != nil {
397+
c.config.Network.GasPriceEstimationEnabled = false
398+
c.config.Network.PrivateKeys = []string{}
399+
}
400+
401+
for i := range c.config.Networks {
402+
c.config.Networks[i].PrivateKeys = []string{}
403+
}
404+
}
405+
}
406+
407+
func (c *ClientBuilder) validateConfig() {
408+
if c.config.Network != nil {
409+
if len(c.config.Network.PrivateKeys) == 0 && c.config.CheckRpcHealthOnStart {
410+
c.errors = append(c.errors, errors.New(NoPkForRpcHealthCheckErr))
411+
}
412+
if len(c.config.Network.PrivateKeys) == 0 && c.config.PendingNonceProtectionEnabled {
413+
c.errors = append(c.errors, errors.New(NoPkForNonceProtection))
414+
}
415+
if len(c.config.Network.PrivateKeys) == 0 && c.config.EphemeralAddrs != nil && *c.config.EphemeralAddrs > 0 {
416+
c.errors = append(c.errors, errors.New(NoPkForEphemeralKeys))
417+
}
418+
if len(c.config.Network.PrivateKeys) == 0 && c.config.Network.GasPriceEstimationEnabled {
419+
c.errors = append(c.errors, errors.New(NoPkForGasPriceEstimation))
420+
}
421+
}
422+
}
423+
375424
func (c *ClientBuilder) checkIfNetworkIsSet() bool {
376425
if c.config.Network == nil {
377426
c.errors = append(c.errors, errors.New("at least one method that required network to be set was called, but network is nil"))

seth/client_helpers.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,42 @@ package seth
22

33
import (
44
"crypto/ecdsa"
5-
"errors"
65

76
"github.com/ethereum/go-ethereum/common"
87
)
98

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

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

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

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

0 commit comments

Comments
 (0)