Skip to content

Commit ae186e7

Browse files
committed
add support for passing custom ethclient to Seth (including Simulated Backend)
1 parent 891897a commit ae186e7

File tree

11 files changed

+625
-487
lines changed

11 files changed

+625
-487
lines changed

book/src/libs/seth.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Reliable and debug-friendly Ethereum client
2222
5. [Configuration](#config)
2323
1. [Simplified configuration](#simplified-configuration)
2424
2. [ClientBuilder](#clientbuilder)
25+
1. [Simulated Backend](#simulated-backend)
2526
3. [Supported env vars](#supported-env-vars)
2627
4. [TOML configuration](#toml-configuration)
2728
6. [Automated gas price estimation](#automatic-gas-estimator)
@@ -263,6 +264,67 @@ if err != nil {
263264
```
264265
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.
265266

267+
### Simulated Backend
268+
269+
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
270+
in-memory environment. When using that option bear in mind that:
271+
* passing RPC URL is not allowed and will result in error
272+
* tracing is disabled
273+
274+
> [!NOTE]
275+
> 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.
276+
277+
So how do you use Seth with simulated backend?
278+
```go
279+
var startBackend := func(fundedAddresses []common.Address) (*simulated.Backend, context.CancelFunc) {
280+
toFund := make(map[common.Address]types.Account)
281+
for _, address := range fundedAddresses {
282+
toFund[address] = types.Account{
283+
Balance: big.NewInt(1000000000000000000), // 1 Ether
284+
}
285+
}
286+
backend := simulated.NewBackend(toFund)
287+
288+
ctx, cancelFn := context.WithCancel(context.Background())
289+
290+
// 100ms block time
291+
ticker := time.NewTicker(100 * time.Millisecond)
292+
go func() {
293+
for {
294+
select {
295+
case <-ticker.C:
296+
backend.Commit()
297+
case <-ctx.Done():
298+
backend.Close()
299+
return
300+
}
301+
}
302+
}()
303+
304+
return backend, cancelFn
305+
}
306+
307+
// 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 is the default dev account
308+
backend, cancelFn := startBackend(
309+
[]common.Address{common.HexToAddress("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")},
310+
)
311+
defer func() { cancelFn() }()
312+
313+
client, err := builder.
314+
WithNetworkName("simulated").
315+
WithEthClient(backend.Client()).
316+
WithPrivateKeys([]string{"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"}).
317+
Build()
318+
319+
require.NoError(t, err, "failed to build client")
320+
_ = client
321+
```
322+
323+
> [!WARNING]
324+
> When using `simulated.Backend` do remember that it doesn't automatically mine blocks. You need to call `backend.Commit()` manually
325+
> to mine a new block and have your transactions processed. The best way to do it is having a goroutine running in the background
326+
> that either mines at specific intervals or when it receives a message on channel.
327+
266328
### Supported env vars
267329

268330
Some crucial data is stored in env vars, create `.envrc` and use `source .envrc`, or use `direnv`

seth/client.go

Lines changed: 64 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import (
55
"crypto/ecdsa"
66
"fmt"
77
"math/big"
8+
"net/http"
89
"path/filepath"
910
"reflect"
1011
"strings"
1112
"time"
1213

1314
"github.com/avast/retry-go"
1415
"github.com/ethereum/go-ethereum"
16+
"github.com/ethereum/go-ethereum/ethclient"
1517
"github.com/ethereum/go-ethereum/ethclient/simulated"
18+
"github.com/ethereum/go-ethereum/rpc"
1619

1720
"github.com/ethereum/go-ethereum/accounts/abi"
1821
"github.com/ethereum/go-ethereum/accounts/abi/bind"
@@ -137,23 +140,26 @@ func NewClientWithConfig(cfg *Config) (*Client, error) {
137140
}
138141

139142
abiFinder := NewABIFinder(contractAddressToNameMap, cs)
140-
if len(cfg.Network.URLs) == 0 {
141-
return nil, fmt.Errorf("at least one url should be present in config in 'secret_urls = []'")
142-
}
143-
tr, err := NewTracer(cs, &abiFinder, cfg, contractAddressToNameMap, addrs)
144-
if err != nil {
145-
return nil, errors.Wrap(err, ErrCreateTracer)
143+
144+
var opts []ClientOpt
145+
146+
// even if the ethclient that was passed supports tracing, we still need the RPC URL, because we cannot get from
147+
// the instance of ethclient, since it doesn't expose any such method
148+
if (cfg.ethclient != nil && shouldIntialiseTracer(cfg.ethclient, cfg) && len(cfg.Network.URLs) > 0) || cfg.ethclient == nil {
149+
tr, err := NewTracer(cs, &abiFinder, cfg, contractAddressToNameMap, addrs)
150+
if err != nil {
151+
return nil, errors.Wrap(err, ErrCreateTracer)
152+
}
153+
opts = append(opts, WithTracer(tr))
146154
}
147155

156+
opts = append(opts, WithContractStore(cs), WithNonceManager(nm), WithContractMap(contractAddressToNameMap), WithABIFinder(&abiFinder))
157+
148158
return NewClientRaw(
149159
cfg,
150160
addrs,
151161
pkeys,
152-
WithContractStore(cs),
153-
WithNonceManager(nm),
154-
WithTracer(tr),
155-
WithContractMap(contractAddressToNameMap),
156-
WithABIFinder(&abiFinder),
162+
opts...,
157163
)
158164
}
159165

@@ -177,39 +183,46 @@ func NewClientRaw(
177183
return nil, errors.New(ErrReadOnlyWithPrivateKeys)
178184
}
179185

180-
// TODO we should execute this only if we haven't passed an instance of Client
181-
// or... we should externalize client creation to a separate function
182-
// and by default create a new instance of Client using the URL from the config
183-
// althouth that would change the API a bit
184-
185-
// if len(cfg.Network.URLs) == 0 {
186-
// return nil, errors.New("no RPC URL provided")
187-
// }
188-
189-
// if len(cfg.Network.URLs) > 1 {
190-
// L.Warn().Msg("Multiple RPC URLs provided, only the first one will be used")
191-
// }
192-
193-
// ctx, cancel := context.WithTimeout(context.Background(), cfg.Network.DialTimeout.Duration())
194-
// defer cancel()
195-
// rpcClient, err := rpc.DialOptions(ctx,
196-
// cfg.FirstNetworkURL(),
197-
// rpc.WithHeaders(cfg.RPCHeaders),
198-
// rpc.WithHTTPClient(&http.Client{
199-
// Transport: NewLoggingTransport(),
200-
// }),
201-
// )
202-
// if err != nil {
203-
// return nil, fmt.Errorf("failed to connect RPC client to '%s' due to: %w", cfg.FirstNetworkURL(), err)
204-
// }
205-
// client := ethclient.NewClient(rpcClient)
186+
var firstUrl string
187+
var client simulated.Client
188+
if cfg.ethclient == nil {
189+
L.Info().Msg("Creating new ethereum client")
190+
if len(cfg.Network.URLs) == 0 {
191+
return nil, errors.New("no RPC URL provided")
192+
}
193+
194+
if len(cfg.Network.URLs) > 1 {
195+
L.Warn().Msg("Multiple RPC URLs provided, only the first one will be used")
196+
}
197+
198+
ctx, cancel := context.WithTimeout(context.Background(), cfg.Network.DialTimeout.Duration())
199+
defer cancel()
200+
rpcClient, err := rpc.DialOptions(ctx,
201+
cfg.MustFirstNetworkURL(),
202+
rpc.WithHeaders(cfg.RPCHeaders),
203+
rpc.WithHTTPClient(&http.Client{
204+
Transport: NewLoggingTransport(),
205+
}),
206+
)
207+
if err != nil {
208+
return nil, fmt.Errorf("failed to connect RPC client to '%s' due to: %w", cfg.MustFirstNetworkURL(), err)
209+
}
210+
client = ethclient.NewClient(rpcClient)
211+
firstUrl = cfg.MustFirstNetworkURL()
212+
} else {
213+
L.Info().
214+
Str("Type", reflect.TypeOf(cfg.ethclient).String()).
215+
Msg("Using provided ethereum client")
216+
client = cfg.ethclient
217+
}
206218

207219
ctx, cancelFunc := context.WithCancel(context.Background())
208220
c := &Client{
221+
Client: client,
209222
Cfg: cfg,
210223
Addresses: addrs,
211224
PrivateKeys: pkeys,
212-
URL: cfg.FirstNetworkURL(),
225+
URL: firstUrl,
213226
ChainID: mustSafeInt64(cfg.Network.ChainID),
214227
Context: ctx,
215228
CancelFunc: cancelFunc,
@@ -225,7 +238,7 @@ func NewClientRaw(
225238
return nil, errors.Wrap(err, "failed to get chain ID")
226239
}
227240
cfg.Network.ChainID = chainId.Uint64()
228-
c.ChainID = int64(cfg.Network.ChainID)
241+
c.ChainID = mustSafeInt64(cfg.Network.ChainID)
229242
}
230243

231244
var err error
@@ -287,7 +300,7 @@ func NewClientRaw(
287300
L.Info().
288301
Str("NetworkName", cfg.Network.Name).
289302
Interface("Addresses", addrs).
290-
Str("RPC", cfg.FirstNetworkURL()).
303+
Str("RPC", firstUrl).
291304
Uint64("ChainID", cfg.Network.ChainID).
292305
Int64("Ephemeral keys", *cfg.EphemeralAddrs).
293306
Msg("Created new client")
@@ -324,7 +337,9 @@ func NewClientRaw(
324337
}
325338
}
326339

327-
if c.Cfg.TracingLevel != TracingLevel_None && c.Tracer == nil {
340+
// we cannot use the tracer with simulated backend, because it doesn't expose a method to get rpcClient (even though it has one)
341+
// and Tracer needs rpcClient to call debug_traceTransaction
342+
if shouldIntialiseTracer(c.Client, cfg) && c.Cfg.TracingLevel != TracingLevel_None && c.Tracer == nil {
328343
if c.ContractStore == nil {
329344
cs, err := NewContractStore(filepath.Join(cfg.ConfigDir, cfg.ABIDir), filepath.Join(cfg.ConfigDir, cfg.BINDir), cfg.GethWrappersDirs)
330345
if err != nil {
@@ -537,12 +552,6 @@ func WithTracer(t *Tracer) ClientOpt {
537552
}
538553
}
539554

540-
func WithEthClient(ethClient simulated.Client) ClientOpt {
541-
return func(c *Client) {
542-
c.Client = ethClient
543-
}
544-
}
545-
546555
/* CallOpts function options */
547556

548557
// CallOpt is a functional option for bind.CallOpts
@@ -1401,3 +1410,11 @@ func (m *Client) mergeLogMeta(pe *DecodedTransactionLog, l types.Log) {
14011410
pe.TXIndex = l.TxIndex
14021411
pe.Removed = l.Removed
14031412
}
1413+
1414+
func shouldIntialiseTracer(client simulated.Client, cfg *Config) bool {
1415+
return len(cfg.Network.URLs) > 0 && supportsTracing(client)
1416+
}
1417+
1418+
func supportsTracing(client simulated.Client) bool {
1419+
return strings.Contains(reflect.TypeOf(client).String(), "ethclient.Client")
1420+
}

seth/client_builder.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"time"
66

7+
"github.com/ethereum/go-ethereum/ethclient/simulated"
78
"github.com/pkg/errors"
89
)
910

@@ -12,6 +13,7 @@ const (
1213
NoPkForNonceProtection = "you need to provide at least one private key to enable nonce protection"
1314
NoPkForEphemeralKeys = "you need to provide at least one private key to generate and fund ephemeral addresses"
1415
NoPkForGasPriceEstimation = "you need to provide at least one private key to enable gas price estimations"
16+
EthClientAndUrlsSet = "you cannot set both EthClient and RPC URLs"
1517
)
1618

1719
type ClientBuilder struct {
@@ -363,6 +365,14 @@ func (c *ClientBuilder) WithNonceManager(rateLimitSec int, retries uint, timeout
363365
return c
364366
}
365367

368+
// WithEthClient sets the ethclient to use. It means that the URL you pass will be ignored and the client will use the provided ethclient,
369+
// but what it allows you is to use Geth's Simulated Backend or similar implementations for testing.
370+
// Default value is nil.
371+
func (c *ClientBuilder) WithEthClient(ethclient simulated.Client) *ClientBuilder {
372+
c.config.ethclient = ethclient
373+
return c
374+
}
375+
366376
// WithReadOnlyMode sets the client to read-only mode. It removes all private keys from all Networks and disables nonce protection and ephemeral addresses.
367377
func (c *ClientBuilder) WithReadOnlyMode() *ClientBuilder {
368378
c.readonly = true
@@ -423,6 +433,9 @@ func (c *ClientBuilder) validateConfig() {
423433
if len(c.config.Network.PrivateKeys) == 0 && c.config.Network.GasPriceEstimationEnabled {
424434
c.errors = append(c.errors, errors.New(NoPkForGasPriceEstimation))
425435
}
436+
if len(c.config.Network.URLs) > 0 && c.config.ethclient != nil {
437+
c.errors = append(c.errors, errors.New(EthClientAndUrlsSet))
438+
}
426439
}
427440
}
428441

0 commit comments

Comments
 (0)