Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TT-1936] Seth with simulated backend #1593

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions book/src/libs/seth.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Reliable and debug-friendly Ethereum client
5. [Configuration](#config)
1. [Simplified configuration](#simplified-configuration)
2. [ClientBuilder](#clientbuilder)
1. [Simulated Backend](#simulated-backend)
3. [Supported env vars](#supported-env-vars)
4. [TOML configuration](#toml-configuration)
6. [Automated gas price estimation](#automatic-gas-estimator)
Expand Down Expand Up @@ -263,6 +264,67 @@ if err != nil {
```
This can be useful if you already have a config, but want to modify it slightly. It can also be useful if you read TOML config with multiple `Networks` and you want to specify which one you want to use.

### Simulated Backend

Last, but not least, `ClientBuilder` allows you to pass custom implementation of `simulated.Client` interface, which include Geth's [Simulated Backend](https://github.com/ethereum/go-ethereum/blob/master/ethclient/simulated/backend.go), which might be very useful for rapid testing against
in-memory environment. When using that option bear in mind that:
* passing RPC URL is not allowed and will result in error
* tracing is disabled

> [!NOTE]
> Simulated Backend doesn't support tracing, because it doesn't expose the JSON-RPC `Call(result interface{}, method string, args ...interface{})` method, which we use to fetch debug information.
So how do you use Seth with simulated backend?
```go
var startBackend := func(fundedAddresses []common.Address) (*simulated.Backend, context.CancelFunc) {
toFund := make(map[common.Address]types.Account)
for _, address := range fundedAddresses {
toFund[address] = types.Account{
Balance: big.NewInt(1000000000000000000), // 1 Ether
}
}
backend := simulated.NewBackend(toFund)

ctx, cancelFn := context.WithCancel(context.Background())

// 100ms block time
ticker := time.NewTicker(100 * time.Millisecond)
go func() {
for {
select {
case <-ticker.C:
backend.Commit()
case <-ctx.Done():
backend.Close()
return
}
}
}()

return backend, cancelFn
}

// 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 is the default dev account
backend, cancelFn := startBackend(
[]common.Address{common.HexToAddress("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")},
)
defer func() { cancelFn() }()

client, err := builder.
WithNetworkName("simulated").
WithEthClient(backend.Client()).
WithPrivateKeys([]string{"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"}).
Build()

require.NoError(t, err, "failed to build client")
_ = client
```

> [!WARNING]
> When using `simulated.Backend` do remember that it doesn't automatically mine blocks. You need to call `backend.Commit()` manually
> to mine a new block and have your transactions processed. The best way to do it is having a goroutine running in the background
> that either mines at specific intervals or when it receives a message on channel.
### Supported env vars

Some crucial data is stored in env vars, create `.envrc` and use `source .envrc`, or use `direnv`
Expand Down
115 changes: 73 additions & 42 deletions seth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import (

"github.com/avast/retry-go"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/ethclient/simulated"
"github.com/ethereum/go-ethereum/rpc"

"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"golang.org/x/sync/errgroup"
Expand Down Expand Up @@ -64,7 +65,7 @@ var (
// Client is a vanilla go-ethereum client with enhanced debug logging
type Client struct {
Cfg *Config
Client *ethclient.Client
Client simulated.Client
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it replace the ethclient.Client completely? In the dev-platform CLI we're using seth.Client also in the production environment. Our question and request was to be able to swap the real client with a simualted one, so in unit/isolated tests we don't need to interact with Anvil (or similar), but rather in-memory simulated backend.

Here's example how we're constructing seth.Client: https://github.com/smartcontractkit/dev-platform/blob/main/cmd/client/eth_client.go#L94
And here's example how it's used to interact with capabilities registry contract: https://github.com/smartcontractkit/dev-platform/blob/main/cmd/client/capabilities_registry_client.go.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it doesn't. simulated.Client is just an interface, you can pass *ethclient.Client as simulated.Client. Anyway, this only applies if you pass simulated backed explicitly to the builder like this WithEthClient(backend.Client()).. Otherwise it will use the URL provided and start a normal client.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thanks for clarification 🙇

Addresses []common.Address
PrivateKeys []*ecdsa.PrivateKey
ChainID int64
Expand Down Expand Up @@ -139,23 +140,26 @@ func NewClientWithConfig(cfg *Config) (*Client, error) {
}

abiFinder := NewABIFinder(contractAddressToNameMap, cs)
if len(cfg.Network.URLs) == 0 {
return nil, fmt.Errorf("at least one url should be present in config in 'secret_urls = []'")
}
tr, err := NewTracer(cs, &abiFinder, cfg, contractAddressToNameMap, addrs)
if err != nil {
return nil, errors.Wrap(err, ErrCreateTracer)

var opts []ClientOpt

// even if the ethclient that was passed supports tracing, we still need the RPC URL, because we cannot get from
// the instance of ethclient, since it doesn't expose any such method
if (cfg.ethclient != nil && shouldIntialiseTracer(cfg.ethclient, cfg) && len(cfg.Network.URLs) > 0) || cfg.ethclient == nil {
tr, err := NewTracer(cs, &abiFinder, cfg, contractAddressToNameMap, addrs)
if err != nil {
return nil, errors.Wrap(err, ErrCreateTracer)
}
opts = append(opts, WithTracer(tr))
}

opts = append(opts, WithContractStore(cs), WithNonceManager(nm), WithContractMap(contractAddressToNameMap), WithABIFinder(&abiFinder))

return NewClientRaw(
cfg,
addrs,
pkeys,
WithContractStore(cs),
WithNonceManager(nm),
WithTracer(tr),
WithContractMap(contractAddressToNameMap),
WithABIFinder(&abiFinder),
opts...,
)
}

Expand All @@ -175,53 +179,70 @@ func NewClientRaw(
pkeys []*ecdsa.PrivateKey,
opts ...ClientOpt,
) (*Client, error) {
if len(cfg.Network.URLs) == 0 {
return nil, errors.New("no RPC URL provided")
}
if len(cfg.Network.URLs) > 1 {
L.Warn().Msg("Multiple RPC URLs provided, only the first one will be used")
}

if cfg.ReadOnly && (len(addrs) > 0 || len(pkeys) > 0) {
return nil, errors.New(ErrReadOnlyWithPrivateKeys)
}

ctx, cancel := context.WithTimeout(context.Background(), cfg.Network.DialTimeout.Duration())
defer cancel()
rpcClient, err := rpc.DialOptions(ctx,
cfg.FirstNetworkURL(),
rpc.WithHeaders(cfg.RPCHeaders),
rpc.WithHTTPClient(&http.Client{
Transport: NewLoggingTransport(),
}),
)
if err != nil {
return nil, fmt.Errorf("failed to connect RPC client to '%s' due to: %w", cfg.FirstNetworkURL(), err)
}
client := ethclient.NewClient(rpcClient)
var firstUrl string
var client simulated.Client
if cfg.ethclient == nil {
L.Info().Msg("Creating new ethereum client")
if len(cfg.Network.URLs) == 0 {
return nil, errors.New("no RPC URL provided")
}

if cfg.Network.ChainID == 0 {
chainId, err := client.ChainID(context.Background())
if len(cfg.Network.URLs) > 1 {
L.Warn().Msg("Multiple RPC URLs provided, only the first one will be used")
}

ctx, cancel := context.WithTimeout(context.Background(), cfg.Network.DialTimeout.Duration())
defer cancel()
rpcClient, err := rpc.DialOptions(ctx,
cfg.MustFirstNetworkURL(),
rpc.WithHeaders(cfg.RPCHeaders),
rpc.WithHTTPClient(&http.Client{
Transport: NewLoggingTransport(),
}),
)
if err != nil {
return nil, errors.Wrap(err, "failed to get chain ID")
return nil, fmt.Errorf("failed to connect RPC client to '%s' due to: %w", cfg.MustFirstNetworkURL(), err)
}
cfg.Network.ChainID = chainId.Uint64()
client = ethclient.NewClient(rpcClient)
firstUrl = cfg.MustFirstNetworkURL()
} else {
L.Info().
Str("Type", reflect.TypeOf(cfg.ethclient).String()).
Msg("Using provided ethereum client")
client = cfg.ethclient
}

ctx, cancelFunc := context.WithCancel(context.Background())
c := &Client{
Cfg: cfg,
Client: client,
Cfg: cfg,
Addresses: addrs,
PrivateKeys: pkeys,
URL: cfg.FirstNetworkURL(),
URL: firstUrl,
ChainID: mustSafeInt64(cfg.Network.ChainID),
Context: ctx,
CancelFunc: cancelFunc,
}

for _, o := range opts {
o(c)
}

if cfg.Network.ChainID == 0 {
chainId, err := c.Client.ChainID(context.Background())
if err != nil {
return nil, errors.Wrap(err, "failed to get chain ID")
}
cfg.Network.ChainID = chainId.Uint64()
c.ChainID = mustSafeInt64(cfg.Network.ChainID)
}

var err error

if c.ContractAddressToNameMap.addressMap == nil {
c.ContractAddressToNameMap = NewEmptyContractMap()
if !cfg.IsSimulatedNetwork() {
Expand Down Expand Up @@ -279,7 +300,7 @@ func NewClientRaw(
L.Info().
Str("NetworkName", cfg.Network.Name).
Interface("Addresses", addrs).
Str("RPC", cfg.FirstNetworkURL()).
Str("RPC", firstUrl).
Uint64("ChainID", cfg.Network.ChainID).
Int64("Ephemeral keys", *cfg.EphemeralAddrs).
Msg("Created new client")
Expand Down Expand Up @@ -316,7 +337,9 @@ func NewClientRaw(
}
}

if c.Cfg.TracingLevel != TracingLevel_None && c.Tracer == nil {
// we cannot use the tracer with simulated backend, because it doesn't expose a method to get rpcClient (even though it has one)
// and Tracer needs rpcClient to call debug_traceTransaction
if shouldIntialiseTracer(c.Client, cfg) && c.Cfg.TracingLevel != TracingLevel_None && c.Tracer == nil {
if c.ContractStore == nil {
cs, err := NewContractStore(filepath.Join(cfg.ConfigDir, cfg.ABIDir), filepath.Join(cfg.ConfigDir, cfg.BINDir), cfg.GethWrappersDirs)
if err != nil {
Expand Down Expand Up @@ -407,7 +430,7 @@ func (m *Client) TransferETHFromKey(ctx context.Context, fromKeyNum int, to stri
ctx, chainCancel := context.WithTimeout(ctx, m.Cfg.Network.TxnTimeout.Duration())
defer chainCancel()

chainID, err := m.Client.NetworkID(ctx)
chainID, err := m.Client.ChainID(ctx)
if err != nil {
return errors.Wrap(err, "failed to get network ID")
}
Expand Down Expand Up @@ -1385,3 +1408,11 @@ func (m *Client) mergeLogMeta(pe *DecodedTransactionLog, l types.Log) {
pe.TXIndex = l.TxIndex
pe.Removed = l.Removed
}

func shouldIntialiseTracer(client simulated.Client, cfg *Config) bool {
return len(cfg.Network.URLs) > 0 && supportsTracing(client)
}

func supportsTracing(client simulated.Client) bool {
return strings.Contains(reflect.TypeOf(client).String(), "ethclient.Client")
}
13 changes: 13 additions & 0 deletions seth/client_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"time"

"github.com/ethereum/go-ethereum/ethclient/simulated"
"github.com/pkg/errors"
)

Expand All @@ -12,6 +13,7 @@ const (
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"
EthClientAndUrlsSet = "you cannot set both EthClient and RPC URLs"
)

type ClientBuilder struct {
Expand Down Expand Up @@ -363,6 +365,14 @@ func (c *ClientBuilder) WithNonceManager(rateLimitSec int, retries uint, timeout
return c
}

// WithEthClient sets the ethclient to use. It means that the URL you pass will be ignored and the client will use the provided ethclient,
// but what it allows you is to use Geth's Simulated Backend or similar implementations for testing.
// Default value is nil.
func (c *ClientBuilder) WithEthClient(ethclient simulated.Client) *ClientBuilder {
c.config.ethclient = ethclient
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
Expand Down Expand Up @@ -423,6 +433,9 @@ func (c *ClientBuilder) validateConfig() {
if len(c.config.Network.PrivateKeys) == 0 && c.config.Network.GasPriceEstimationEnabled {
c.errors = append(c.errors, errors.New(NoPkForGasPriceEstimation))
}
if len(c.config.Network.URLs) > 0 && c.config.ethclient != nil {
c.errors = append(c.errors, errors.New(EthClientAndUrlsSet))
}
}
}

Expand Down
Loading
Loading