Skip to content

Commit d78174f

Browse files
feat: migrate onchain client
copy multiclient and rpc_config from chainlink repo to here. JIRA: https://smartcontract-it.atlassian.net/browse/CLD-130
1 parent 0c79d41 commit d78174f

File tree

7 files changed

+507
-0
lines changed

7 files changed

+507
-0
lines changed

deployment/environment.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package deployment
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"math/big"
8+
9+
"github.com/ethereum/go-ethereum/accounts/abi/bind"
10+
"github.com/ethereum/go-ethereum/common"
11+
"github.com/ethereum/go-ethereum/rpc"
12+
)
13+
14+
// OnchainClient is an EVM chain client.
15+
// For EVM specifically we can use existing geth interface
16+
// to abstract chain clients.
17+
type OnchainClient interface {
18+
bind.ContractBackend
19+
bind.DeployBackend
20+
BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error)
21+
NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error)
22+
}
23+
24+
func MaybeDataErr(err error) error {
25+
//revive:disable
26+
var d rpc.DataError
27+
ok := errors.As(err, &d)
28+
if ok {
29+
return fmt.Errorf("%s: %v", d.Error(), d.ErrorData())
30+
}
31+
return err
32+
}

deployment/multiclient.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package deployment
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"math/big"
8+
"time"
9+
10+
"github.com/avast/retry-go/v4"
11+
"github.com/ethereum/go-ethereum"
12+
"github.com/ethereum/go-ethereum/accounts/abi/bind"
13+
"github.com/ethereum/go-ethereum/common"
14+
"github.com/ethereum/go-ethereum/core/types"
15+
"github.com/ethereum/go-ethereum/ethclient"
16+
17+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
18+
19+
chainsel "github.com/smartcontractkit/chain-selectors"
20+
)
21+
22+
const (
23+
// Default retry configuration for RPC calls
24+
RPCDefaultRetryAttempts = 10
25+
RPCDefaultRetryDelay = 1000 * time.Millisecond
26+
27+
// Default retry configuration for dialing RPC endpoints
28+
RPCDefaultDialRetryAttempts = 10
29+
RPCDefaultDialRetryDelay = 1000 * time.Millisecond
30+
)
31+
32+
type RetryConfig struct {
33+
Attempts uint
34+
Delay time.Duration
35+
}
36+
37+
func defaultRetryConfig() RetryConfig {
38+
return RetryConfig{
39+
Attempts: RPCDefaultRetryAttempts,
40+
Delay: RPCDefaultRetryDelay,
41+
}
42+
}
43+
44+
// MultiClient should comply with the OnchainClient interface
45+
var _ OnchainClient = &MultiClient{}
46+
47+
type MultiClient struct {
48+
*ethclient.Client
49+
Backups []*ethclient.Client
50+
RetryConfig RetryConfig
51+
lggr logger.Logger
52+
chainName string
53+
}
54+
55+
func NewMultiClient(lggr logger.Logger, rpcsCfg RPCConfig, opts ...func(client *MultiClient)) (*MultiClient, error) {
56+
if len(rpcsCfg.RPCs) == 0 {
57+
return nil, errors.New("no RPCs provided, need at least one")
58+
}
59+
// Set the chain name
60+
chain, exists := chainsel.ChainBySelector(rpcsCfg.ChainSelector)
61+
if !exists {
62+
return nil, fmt.Errorf("chain with selector %d not found", rpcsCfg.ChainSelector)
63+
}
64+
mc := MultiClient{lggr: lggr, chainName: chain.Name}
65+
66+
clients := make([]*ethclient.Client, 0, len(rpcsCfg.RPCs))
67+
for i, rpc := range rpcsCfg.RPCs {
68+
client, err := mc.dialWithRetry(rpc, lggr)
69+
if err != nil {
70+
lggr.Warnf("failed to dial client %d for RPC '%s' trying with the next one: %v", i, rpc.Name, err)
71+
continue
72+
}
73+
clients = append(clients, client)
74+
}
75+
76+
if len(clients) == 0 {
77+
return nil, errors.New("no valid RPC clients created")
78+
}
79+
80+
mc.Client = clients[0]
81+
mc.Backups = clients[1:]
82+
mc.RetryConfig = defaultRetryConfig()
83+
84+
for _, opt := range opts {
85+
opt(&mc)
86+
}
87+
return &mc, nil
88+
}
89+
90+
func (mc *MultiClient) SendTransaction(ctx context.Context, tx *types.Transaction) error {
91+
return mc.retryWithBackups("SendTransaction", func(client *ethclient.Client) error {
92+
return client.SendTransaction(ctx, tx)
93+
})
94+
}
95+
96+
func (mc *MultiClient) CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) {
97+
var result []byte
98+
err := mc.retryWithBackups("CallContract", func(client *ethclient.Client) error {
99+
var err error
100+
result, err = client.CallContract(ctx, msg, blockNumber)
101+
return err
102+
})
103+
return result, err
104+
}
105+
106+
func (mc *MultiClient) CallContractAtHash(ctx context.Context, msg ethereum.CallMsg, blockHash common.Hash) ([]byte, error) {
107+
var result []byte
108+
err := mc.retryWithBackups("CallContractAtHash", func(client *ethclient.Client) error {
109+
var err error
110+
result, err = client.CallContractAtHash(ctx, msg, blockHash)
111+
return err
112+
})
113+
return result, err
114+
}
115+
116+
func (mc *MultiClient) CodeAt(ctx context.Context, account common.Address, blockNumber *big.Int) ([]byte, error) {
117+
var code []byte
118+
err := mc.retryWithBackups("CodeAt", func(client *ethclient.Client) error {
119+
var err error
120+
code, err = client.CodeAt(ctx, account, blockNumber)
121+
return err
122+
})
123+
return code, err
124+
}
125+
126+
func (mc *MultiClient) CodeAtHash(ctx context.Context, account common.Address, blockHash common.Hash) ([]byte, error) {
127+
var code []byte
128+
err := mc.retryWithBackups("CodeAtHash", func(client *ethclient.Client) error {
129+
var err error
130+
code, err = client.CodeAtHash(ctx, account, blockHash)
131+
return err
132+
})
133+
return code, err
134+
}
135+
136+
func (mc *MultiClient) NonceAt(ctx context.Context, account common.Address, block *big.Int) (uint64, error) {
137+
var count uint64
138+
err := mc.retryWithBackups("NonceAt", func(client *ethclient.Client) error {
139+
var err error
140+
count, err = client.NonceAt(ctx, account, block)
141+
return err
142+
})
143+
return count, err
144+
}
145+
146+
func (mc *MultiClient) NonceAtHash(ctx context.Context, account common.Address, blockHash common.Hash) (uint64, error) {
147+
var count uint64
148+
err := mc.retryWithBackups("NonceAtHash", func(client *ethclient.Client) error {
149+
var err error
150+
count, err = client.NonceAtHash(ctx, account, blockHash)
151+
return err
152+
})
153+
return count, err
154+
}
155+
156+
func (mc *MultiClient) WaitMined(ctx context.Context, tx *types.Transaction) (*types.Receipt, error) {
157+
mc.lggr.Debugf("Waiting for tx %s to be mined for chain %s", tx.Hash().Hex(), mc.chainName)
158+
// no retries here because we want to wait for the tx to be mined
159+
resultCh := make(chan *types.Receipt)
160+
doneCh := make(chan struct{})
161+
162+
waitMined := func(client *ethclient.Client, tx *types.Transaction) {
163+
mc.lggr.Debugf("Waiting for tx %s to be mined with chain %s", tx.Hash().Hex(), mc.chainName)
164+
receipt, err := bind.WaitMined(ctx, client, tx)
165+
if err != nil {
166+
mc.lggr.Warnf("WaitMined error %v with chain %s", err, mc.chainName)
167+
return
168+
}
169+
select {
170+
case resultCh <- receipt:
171+
case <-doneCh:
172+
return
173+
}
174+
}
175+
176+
for _, client := range append([]*ethclient.Client{mc.Client}, mc.Backups...) {
177+
go waitMined(client, tx)
178+
}
179+
var receipt *types.Receipt
180+
select {
181+
case receipt = <-resultCh:
182+
close(doneCh)
183+
mc.lggr.Debugf("Tx %s mined with chain %s", tx.Hash().Hex(), mc.chainName)
184+
return receipt, nil
185+
case <-ctx.Done():
186+
mc.lggr.Warnf("WaitMined context done %v", ctx.Err())
187+
close(doneCh)
188+
return nil, ctx.Err()
189+
}
190+
}
191+
192+
func (mc *MultiClient) retryWithBackups(opName string, op func(*ethclient.Client) error) error {
193+
var err error
194+
for i, client := range append([]*ethclient.Client{mc.Client}, mc.Backups...) {
195+
err2 := retry.Do(func() error {
196+
mc.lggr.Debugf("Trying op %s with chain %s client index %d", opName, mc.chainName, i)
197+
err = op(client)
198+
if err != nil {
199+
mc.lggr.Warnf("retryable error '%s' for op %s with chain %s client index %d", MaybeDataErr(err), opName, mc.chainName, i)
200+
return err
201+
}
202+
return nil
203+
}, retry.Attempts(mc.RetryConfig.Attempts), retry.Delay(mc.RetryConfig.Delay))
204+
if err2 == nil {
205+
return nil
206+
}
207+
mc.lggr.Infof("Client at index %d failed, trying next client chain %s", i, mc.chainName)
208+
}
209+
return errors.Join(err, fmt.Errorf("all backup clients %v failed for chain %s", mc.Backups, mc.chainName))
210+
}
211+
212+
func (mc *MultiClient) dialWithRetry(rpc RPC, lggr logger.Logger) (*ethclient.Client, error) {
213+
endpoint, err := rpc.ToEndpoint()
214+
if err != nil {
215+
return nil, err
216+
}
217+
218+
var client *ethclient.Client
219+
err = retry.Do(func() error {
220+
var err2 error
221+
mc.lggr.Debugf("dialing endpoint '%s' for RPC %s for chain %s", endpoint, rpc.Name, mc.chainName)
222+
client, err2 = ethclient.Dial(endpoint)
223+
if err2 != nil {
224+
lggr.Warnf("retryable error for RPC %s:%s for chain %s %v", rpc.Name, endpoint, mc.chainName, err2)
225+
return err2
226+
}
227+
return nil
228+
}, retry.Attempts(RPCDefaultDialRetryAttempts), retry.Delay(RPCDefaultDialRetryDelay))
229+
230+
if err != nil {
231+
return nil, errors.Join(err, fmt.Errorf("failed to dial endpoint '%s' for RPC %s for chain %s after retries", endpoint, rpc.Name, mc.chainName))
232+
}
233+
return client, nil
234+
}

deployment/multiclient_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package deployment
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
10+
)
11+
12+
// TODO(giogam): This test is incomplete, it should be completed with support for websockets URLS
13+
func TestMultiClient(t *testing.T) {
14+
var (
15+
lggr = logger.Test(t)
16+
chainSelector uint64 = 16015286601757825753 // "ethereum-testnet-sepolia"
17+
wsURL = "ws://example.com"
18+
httpURL = "http://example.com"
19+
)
20+
21+
// Expect defaults to be set if not provided.
22+
mc, err := NewMultiClient(lggr, RPCConfig{ChainSelector: chainSelector, RPCs: []RPC{
23+
{Name: "test-rpc", WSURL: wsURL, HTTPURL: httpURL, PreferredURLScheme: URLSchemePreferenceHTTP},
24+
}})
25+
26+
require.NoError(t, err)
27+
require.NotNil(t, mc)
28+
29+
assert.Equal(t, "ethereum-testnet-sepolia", mc.chainName)
30+
assert.Equal(t, mc.RetryConfig.Attempts, uint(RPCDefaultRetryAttempts))
31+
assert.Equal(t, RPCDefaultRetryDelay, mc.RetryConfig.Delay)
32+
33+
// Expect error if no RPCs provided.
34+
_, err = NewMultiClient(lggr, RPCConfig{ChainSelector: chainSelector, RPCs: []RPC{}})
35+
require.Error(t, err)
36+
37+
// Expect second client to be set as backup.
38+
mc, err = NewMultiClient(lggr, RPCConfig{ChainSelector: chainSelector, RPCs: []RPC{
39+
{Name: "test-rpc", WSURL: wsURL, HTTPURL: httpURL, PreferredURLScheme: URLSchemePreferenceHTTP},
40+
{Name: "test-rpc", WSURL: wsURL, HTTPURL: httpURL, PreferredURLScheme: URLSchemePreferenceHTTP},
41+
}})
42+
require.NoError(t, err)
43+
require.Len(t, mc.Backups, 1)
44+
}

deployment/rpc_config.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package deployment
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
type URLSchemePreference int
10+
11+
const (
12+
URLSchemePreferenceNone URLSchemePreference = iota
13+
URLSchemePreferenceWS
14+
URLSchemePreferenceHTTP
15+
)
16+
17+
func URLSchemePreferenceFromString(s string) (URLSchemePreference, error) {
18+
switch strings.ToLower(s) {
19+
case "none":
20+
return URLSchemePreferenceNone, nil
21+
case "ws":
22+
return URLSchemePreferenceWS, nil
23+
case "http":
24+
return URLSchemePreferenceHTTP, nil
25+
default:
26+
return URLSchemePreferenceNone, fmt.Errorf("invalid URLSchemePreference: %s", s)
27+
}
28+
}
29+
30+
func (u *URLSchemePreference) UnmarshalText(text []byte) error {
31+
preference, err := URLSchemePreferenceFromString(string(text))
32+
if err != nil {
33+
return err
34+
}
35+
*u = preference
36+
37+
return nil
38+
}
39+
40+
type RPC struct {
41+
Name string
42+
WSURL string
43+
HTTPURL string
44+
PreferredURLScheme URLSchemePreference
45+
}
46+
47+
// ToEndpoint returns the correct endpoint based on the preferred URL scheme
48+
// If the preferred URL scheme is not set, it will return the WS URL
49+
// If the preferred URL scheme is set to WS, it will return the WS URL
50+
// If the preferred URL scheme is set to HTTP, it will return the HTTP URL
51+
func (r RPC) ToEndpoint() (string, error) {
52+
switch r.PreferredURLScheme {
53+
case URLSchemePreferenceNone, URLSchemePreferenceWS:
54+
return r.WSURL, nil
55+
case URLSchemePreferenceHTTP:
56+
return r.HTTPURL, nil
57+
default:
58+
return "", errors.New("unknown URLSchemePreference")
59+
}
60+
}
61+
62+
// RPCConfig is a configuration for a chain.
63+
// It contains a chain selector and a list of RPCs
64+
type RPCConfig struct {
65+
ChainSelector uint64
66+
RPCs []RPC
67+
}

0 commit comments

Comments
 (0)