Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/true-lizards-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": patch
---

fix: run health check and try multiple RPCs in fork tests
9 changes: 9 additions & 0 deletions chain/evm/provider/ctf_anvil_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ type CTFAnvilChainProviderConfig struct {
// gas limits, or other Anvil-specific options.
DockerCmdParamsOverrides []string

ForkURLs []string
Copy link
Collaborator

Choose a reason for hiding this comment

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

should we add some godoc for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, let me add it


// Optional: Port specifies the port for the Anvil container. If not provided,
// a free port will be automatically allocated. Use this when you need the Anvil
// instance to run on a specific port.
Expand Down Expand Up @@ -538,6 +540,7 @@ func (p *CTFAnvilChainProvider) Cleanup(ctx context.Context) error {
// Returns the external HTTP URL that can be used to connect to the Anvil node.
func (p *CTFAnvilChainProvider) startContainer(ctx context.Context, chainID string) (string, error) {
attempts := uint(10)
attempt := uint(0)

err := framework.DefaultNetwork(p.config.Once)
if err != nil {
Expand All @@ -564,6 +567,11 @@ func (p *CTFAnvilChainProvider) startContainer(ctx context.Context, chainID stri
portStr = strconv.Itoa(port)
}

if len(p.config.ForkURLs) > 0 {
url := p.config.ForkURLs[attempt%uint(len(p.config.ForkURLs))]
p.config.DockerCmdParamsOverrides = append(p.config.DockerCmdParamsOverrides, "--fork-url", url)
Copy link
Collaborator

Choose a reason for hiding this comment

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

so everytime this runs, we will append another --fork-url into p.config.DockerCmdParamsOverrides, so then DockerCmdParamsOverrides will have more than one --fork-url ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ooops; good catch. The earlier tests didn't have the health check, so this code path wasn't re-tested

}
Comment on lines +570 to +573
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The DockerCmdParamsOverrides slice is being mutated on every retry attempt, causing --fork-url parameters to accumulate. Each retry will append new fork-url arguments without clearing previous ones. Store the base overrides separately and create a fresh slice for each attempt, or clear the fork-url entries before appending.

Copilot uses AI. Check for mistakes.

// Create the input for the Anvil blockchain network
input := &blockchain.Input{
Type: blockchain.TypeAnvil,
Expand Down Expand Up @@ -609,6 +617,7 @@ func (p *CTFAnvilChainProvider) startContainer(ctx context.Context, chainID stri
retry.Attempts(attempts),
retry.Delay(1*time.Second),
retry.DelayType(retry.FixedDelay),
retry.OnRetry(func(a uint, err error) { attempt = a + 1 }),
)
if err != nil {
return "", fmt.Errorf("failed to start CTF Anvil container after %d attempts: %w", attempts, err)
Expand Down
3 changes: 0 additions & 3 deletions engine/cld/config/network/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ func (a AnvilConfig) Validate() error {
if a.Port == 0 {
return errors.New("port is not defined")
}
if a.ArchiveHTTPURL == "" {
return errors.New("archive_http_url is not defined")
}

return nil
}
Expand Down
8 changes: 0 additions & 8 deletions engine/cld/config/network/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,6 @@ func Test_AnvilConfig_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "missing archive HTTP URL",
config: AnvilConfig{
Image: "anvil:latest",
Port: 8545,
},
wantErr: true,
},
}

for _, tt := range tests {
Expand Down
70 changes: 46 additions & 24 deletions engine/cld/environment/anvil.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/go-resty/resty/v2"
"github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain"
"github.com/smartcontractkit/freeport"
Expand Down Expand Up @@ -214,11 +215,12 @@ func newAnvilChains(
"failed to decode network metadata for chain selector %d: %w", chainSelector, errMeta,
)
}
if err := selectPublicRPC(lggr, &metadata, network.ChainSelector, network.RPCs); err != nil {
forkURLs, err := selectPublicRPC(ctx, lggr, &metadata, network.ChainSelector, network.RPCs)
if err != nil {
lggr.Infof("Excluding chain with ID %d from environment: %s", chainID, err.Error())
continue
}
if err := metadata.AnvilConfig.Validate(); err != nil {
if err = metadata.AnvilConfig.Validate(); err != nil {
lggr.Infof("Excluding chain with ID %d from environment due to failed anvil config validation: %s", chainID, err.Error())
continue
}
Expand All @@ -232,10 +234,10 @@ func newAnvilChains(

var signerGenerator evmprov.SignerGenerator
if kmsConfig.KeyID != "" {
var err error
signerGenerator, err = evmprov.TransactorFromKMS(kmsConfig.KeyID, kmsConfig.KeyRegion, "")
if err != nil {
return nil, fmt.Errorf("failed to create transactor from KMS: %w", err)
var terr error
signerGenerator, terr = evmprov.TransactorFromKMS(kmsConfig.KeyID, kmsConfig.KeyRegion, "")
if terr != nil {
return nil, fmt.Errorf("failed to create transactor from KMS: %w", terr)
}
} else {
signerGenerator = evmprov.TransactorFromRaw(onchainConfig.EVM.DeployerKey)
Expand All @@ -252,16 +254,14 @@ func newAnvilChains(
}

config := evmprov.CTFAnvilChainProviderConfig{
Once: &once,
ConfirmFunctor: evmprov.ConfirmFuncGeth(3 * time.Minute),
DockerCmdParamsOverrides: []string{
"--fork-url", metadata.AnvilConfig.ArchiveHTTPURL,
"--auto-impersonate",
},
Image: metadata.AnvilConfig.Image,
Port: strconv.FormatUint(metadata.AnvilConfig.Port, 10),
DeployerTransactorGen: signerGenerator,
T: testing.TB(&testing.T{}),
Once: &once,
ConfirmFunctor: evmprov.ConfirmFuncGeth(3 * time.Minute),
DockerCmdParamsOverrides: []string{"--auto-impersonate"},
Image: metadata.AnvilConfig.Image,
ForkURLs: forkURLs,
DeployerTransactorGen: signerGenerator,
T: testing.TB(&testing.T{}),
Port: "", // let the provider choose a free port; this ensures retries are handled properly
}

if blockNumber, ok := blockNumbers[chainSelector]; ok {
Expand Down Expand Up @@ -304,26 +304,48 @@ func newAnvilChains(
}

func selectPublicRPC(
lggr logger.Logger, metadata *cfgnet.EVMMetadata, chainSelector uint64, rpcs []cfgnet.RPC,
) error {
ctx context.Context, lggr logger.Logger, metadata *cfgnet.EVMMetadata, chainSelector uint64, rpcs []cfgnet.RPC,
) ([]string, error) {
if metadata.AnvilConfig.ArchiveHTTPURL != "" && isPublicRPC(metadata.AnvilConfig.ArchiveHTTPURL) {
return nil
return []string{metadata.AnvilConfig.ArchiveHTTPURL}, nil
}

urls := []string{}
for _, rpc := range rpcs {
if isPublicRPC(rpc.HTTPURL) {
metadata.AnvilConfig.ArchiveHTTPURL = rpc.HTTPURL
lggr.Infow("selected rpc for fork environment", "url", rpc.HTTPURL, "chainSelector", chainSelector)

return nil
err := runHealthCheck(ctx, rpc.HTTPURL)
Copy link
Collaborator

Choose a reason for hiding this comment

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

i wonder if it is worth parallelizing here instead of doing sequential health check?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the health checks I ran failed fast, so I didn't bother. We can reassess once we get a bit more real world usage.

if err != nil {
lggr.Infow("rpc failed health check", "url", rpc.HTTPURL, "chainSelector", chainSelector)
} else {
lggr.Infow("selected rpc for fork environment", "url", rpc.HTTPURL, "chainSelector", chainSelector)
urls = append(urls, rpc.HTTPURL)
}
}
}

return fmt.Errorf("no public RPCs found for chain %d", chainSelector)
if len(urls) == 0 {
return []string{}, fmt.Errorf("no public RPCs found for chain %d", chainSelector)
}

return urls, nil
}

var privateRpcRegexp = regexp.MustCompile(`^https?://(rpcs\.cldev\.sh|gap\-.*\.(prod|stage)\.cldev\.sh|.*\.tail[a-z0-9]+\.ts\.net)(?::\d+)?/`)

func isPublicRPC(url string) bool {
return !privateRpcRegexp.MatchString(url)
}

func runHealthCheck(ctx context.Context, rpcURL string) error {
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return fmt.Errorf("failed to connect to rpc %v: %w", rpcURL, err)
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The ethclient connection is never closed, creating a resource leak. Add defer client.Close() after successful dial to ensure proper cleanup of the connection.

Suggested change
}
}
defer client.Close()

Copilot uses AI. Check for mistakes.

_, err = client.BlockNumber(ctx)
if err != nil {
return fmt.Errorf("failed to retrieve block number: %w", err)
}

return nil
}
39 changes: 27 additions & 12 deletions engine/cld/environment/anvil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/go-resty/resty/v2"
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -174,14 +175,18 @@ func Test_isPublicRPC(t *testing.T) {

func Test_selectPublicRPC(t *testing.T) {
t.Parallel()
httpmock.Activate(t)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The httpmock.Activate(t) call activates mocking globally for all tests, but lacks a corresponding Deactivate call. This will cause mock responses to leak into other parallel tests. Add t.Cleanup(httpmock.DeactivateAndReset) after activation to ensure proper cleanup.

Suggested change
httpmock.Activate(t)
httpmock.Activate(t)
t.Cleanup(httpmock.DeactivateAndReset)

Copilot uses AI. Check for mistakes.

lggr := logger.Test(t)
nosetup := func(t *testing.T) { t.Helper() }

tests := []struct {
name string
metadata *cfgnet.EVMMetadata
chainSelector uint64
rpcs []cfgnet.RPC
want *cfgnet.EVMMetadata
setup func(t *testing.T)
want []string
wantErr string
}{
{
Expand All @@ -192,42 +197,52 @@ func Test_selectPublicRPC(t *testing.T) {
rpcs: []cfgnet.RPC{
{HTTPURL: "http://other.url"},
},
want: &cfgnet.EVMMetadata{AnvilConfig: &cfgnet.AnvilConfig{
ArchiveHTTPURL: "http://metadata.url",
}},
setup: nosetup,
want: []string{"http://metadata.url"},
},
{
name: "success: private rpc in metadata is replaced public url from parameters",
name: "success: selects only health public rpcs",
metadata: &cfgnet.EVMMetadata{AnvilConfig: &cfgnet.AnvilConfig{
ArchiveHTTPURL: "http://gap-rpc.prod.cldev.sh/ethereum/sepolia",
}},
rpcs: []cfgnet.RPC{
{HTTPURL: "http://rpcs.cldev.sh/ethereum/sepolia"},
{HTTPURL: "http://public.rpc.url"},
{HTTPURL: "http://public.rpc1.url"},
{HTTPURL: "http://public.rpc2.url"},
{HTTPURL: "http://public.rpc3.url"},
},
want: &cfgnet.EVMMetadata{AnvilConfig: &cfgnet.AnvilConfig{
ArchiveHTTPURL: "http://public.rpc.url",
}},
setup: func(t *testing.T) {
t.Helper()
httpmock.RegisterResponder("POST", "http://public.rpc1.url",
httpmock.NewStringResponder(200, `{"jsonrpc":"2.0","id":1,"result":"0x123"}`))
httpmock.RegisterResponder("POST", "http://public.rpc3.url",
httpmock.NewStringResponder(200, `{"jsonrpc":"2.0","id":1,"result":"0x456"}`))
},
want: []string{"http://public.rpc1.url", "http://public.rpc3.url"},
},
{
name: "failure: no public rpcs found",
name: "failure: no public or healthy rpcs found",
metadata: &cfgnet.EVMMetadata{AnvilConfig: &cfgnet.AnvilConfig{
ArchiveHTTPURL: "http://gap-rpc.prod.cldev.sh/ethereum/sepolia",
}},
rpcs: []cfgnet.RPC{
{HTTPURL: "http://rpcs.cldev.sh/ethereum/sepolia"},
{HTTPURL: "http://unhealthy.rpc.url"},
},
setup: nosetup,
wantErr: "no public RPCs found for chain 0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := selectPublicRPC(lggr, tt.metadata, tt.chainSelector, tt.rpcs)
tt.setup(t)
urls, err := selectPublicRPC(t.Context(), lggr, tt.metadata, tt.chainSelector, tt.rpcs)

if tt.wantErr == "" {
require.NoError(t, err)
require.Equal(t, tt.want, tt.metadata)
require.Equal(t, tt.want, urls)
} else {
require.ErrorContains(t, err, tt.wantErr)
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
github.com/goccy/go-yaml v1.12.0
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/jarcoal/httpmock v1.4.1
github.com/olekukonko/tablewriter v0.0.5
github.com/pelletier/go-toml/v2 v2.2.4
github.com/segmentio/ksuid v1.0.4
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,8 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
Expand Down Expand Up @@ -533,6 +535,8 @@ github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
Expand Down
Loading