Skip to content

Commit bc7e435

Browse files
authored
feat: add contracts verification modular command (#826)
https://smartcontract-it.atlassian.net/browse/CLD-597 Extracted CCIP domain verification code and updated it to be modular command and domain agnostic. Teams will need to have source code imported into domain and have input configuration like current CCIP one. Supports catalog and local files. ( with -local flag, otherwise by default use domain configuration env settings ) Each domain need`ContractInputsProvider` that returns `SolidityContractMetadata` (Standard JSON sources, bytecode, compiler version) for each supported `(contractType, version)` First data is loaded with proper filtering. Then going through contracts in specified env code use proper solidty metadata to verify contract. Domain config contains block explorer API keys and optionally catalog GRPC for CI; use `-n` for network filtering, `-a` for address filtering, and `--local` to skip catalog. A potential GitHub Action job could take `domain`, `environment`, optional `networks` (chain selectors) and `address` filters to verify these by CI.
1 parent ab1f34b commit bc7e435

28 files changed

Lines changed: 3022 additions & 14 deletions

.changeset/khaki-bats-occur.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
feat: extract from cld ccip and make domain agnostic verification cmd for contracts

engine/cld/commands/commands.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ import (
3131
"github.com/spf13/cobra"
3232

3333
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/addressbook"
34+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/contract"
3435
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/datastore"
3536
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/mcms"
3637
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/state"
3738
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
3839
proposalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer"
3940
proposalrenderer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/renderer"
41+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification/evm"
4042
"github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer"
4143
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
4244
)
@@ -108,3 +110,19 @@ func (c *Commands) MCMS(dom domain.Domain, cfg MCMSConfig) (*cobra.Command, erro
108110
ProposalRenderers: cfg.ProposalRenderers,
109111
})
110112
}
113+
114+
// ContractConfig holds configuration for contract verification commands.
115+
type ContractConfig struct {
116+
// ContractInputsProvider supplies contract metadata for verification.
117+
// Required for verify-env. Domain-specific ContractInputsProvider.
118+
ContractInputsProvider evm.ContractInputsProvider
119+
}
120+
121+
// Contract creates the contract command group for verification.
122+
func (c *Commands) Contract(dom domain.Domain, cfg ContractConfig) (*cobra.Command, error) {
123+
return contract.NewCommand(contract.Config{
124+
Logger: c.lggr,
125+
Domain: dom,
126+
ContractInputsProvider: cfg.ContractInputsProvider,
127+
})
128+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package contract
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/text"
11+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
12+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification/evm"
13+
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
14+
)
15+
16+
var (
17+
contractShort = "Contract verification commands"
18+
19+
contractLong = text.LongDesc(`
20+
Commands for verifying deployed contracts on block explorers.
21+
22+
Verify contracts in an environment using the datastore or catalog to discover
23+
contracts. Requires a domain-specific ContractInputsProvider to supply
24+
contract metadata (Standard JSON, bytecode) for verification.
25+
26+
Currently supports only EVM-based chains, but can be extended to other chain types in the future.
27+
`)
28+
)
29+
30+
// Config holds the configuration for contract commands.
31+
type Config struct {
32+
Logger logger.Logger
33+
Domain domain.Domain
34+
ContractInputsProvider evm.ContractInputsProvider
35+
// VerifierHTTPClient is optional; when set, verifiers use it for API calls (e.g. in tests).
36+
VerifierHTTPClient *http.Client
37+
38+
Deps Deps
39+
}
40+
41+
// Validate checks that all required configuration fields are set.
42+
func (c Config) Validate() error {
43+
var missing []string
44+
if c.Logger == nil {
45+
missing = append(missing, "Logger")
46+
}
47+
if c.Domain.RootPath() == "" {
48+
missing = append(missing, "Domain")
49+
}
50+
if c.ContractInputsProvider == nil {
51+
missing = append(missing, "ContractInputsProvider")
52+
}
53+
if len(missing) > 0 {
54+
return errors.New("contract.Config: missing required fields: " + strings.Join(missing, ", "))
55+
}
56+
57+
return nil
58+
}
59+
60+
func (c *Config) deps() {
61+
c.Deps.applyDefaults()
62+
}
63+
64+
// NewCommand creates the contract command with verify-env subcommand.
65+
func NewCommand(cfg Config) (*cobra.Command, error) {
66+
if err := cfg.Validate(); err != nil {
67+
return nil, err
68+
}
69+
cfg.deps()
70+
71+
cmd := &cobra.Command{
72+
Use: "contract",
73+
Short: contractShort,
74+
Long: contractLong,
75+
}
76+
cmd.AddCommand(NewVerifyEnvCmd(cfg))
77+
78+
return cmd, nil
79+
}
80+
81+
// NewVerifyEnvCmd creates the verify-env subcommand. Exported for domains that want
82+
// to add it as a top-level command (e.g. verify-evm) while using the same implementation.
83+
func NewVerifyEnvCmd(cfg Config) *cobra.Command {
84+
return newVerifyEnvCmdWithUse(cfg, "verify-env")
85+
}
86+
87+
// NewVerifyEnvCmdWithUse creates the verify command with a custom Use string.
88+
// Use this when adding as a top-level command with a custom name (e.g. "verify-evm").
89+
func NewVerifyEnvCmdWithUse(cfg Config, use string) *cobra.Command {
90+
return newVerifyEnvCmdWithUse(cfg, use)
91+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package contract
2+
3+
import (
4+
"testing"
5+
6+
"github.com/Masterminds/semver/v3"
7+
"github.com/spf13/cobra"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
11+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
12+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification/evm"
13+
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
14+
)
15+
16+
// mockContractInputsProvider is a no-op provider for testing.
17+
type mockContractInputsProvider struct {
18+
getInputsErr error
19+
}
20+
21+
func (m *mockContractInputsProvider) GetInputs(_ datastore.ContractType, _ *semver.Version) (evm.SolidityContractMetadata, error) {
22+
if m != nil && m.getInputsErr != nil {
23+
return evm.SolidityContractMetadata{}, m.getInputsErr
24+
}
25+
26+
return evm.SolidityContractMetadata{}, nil
27+
}
28+
29+
func newTestContractCommand(t *testing.T) *cobra.Command {
30+
t.Helper()
31+
32+
cmd, err := NewCommand(Config{
33+
Logger: logger.Nop(),
34+
Domain: domain.NewDomain(t.TempDir(), "testdomain"),
35+
ContractInputsProvider: &mockContractInputsProvider{},
36+
})
37+
require.NoError(t, err)
38+
39+
return cmd
40+
}
41+
42+
func TestNewCommand_Structure(t *testing.T) {
43+
t.Parallel()
44+
45+
cmd := newTestContractCommand(t)
46+
47+
require.Equal(t, "contract", cmd.Use)
48+
require.Equal(t, contractShort, cmd.Short)
49+
require.NotEmpty(t, cmd.Long)
50+
51+
subs := cmd.Commands()
52+
require.Len(t, subs, 1)
53+
require.Equal(t, "verify-env", subs[0].Use)
54+
}
55+
56+
func TestNewVerifyEnvCmdWithUse_CustomUse(t *testing.T) {
57+
t.Parallel()
58+
59+
cmd := NewVerifyEnvCmdWithUse(Config{
60+
Logger: logger.Nop(),
61+
Domain: domain.NewDomain(t.TempDir(), "testdomain"),
62+
ContractInputsProvider: &mockContractInputsProvider{},
63+
}, "verify-evm")
64+
65+
require.NotNil(t, cmd)
66+
require.Equal(t, "verify-evm", cmd.Use)
67+
}
68+
69+
func TestVerifyEnv_MissingEnvironmentFlagFails(t *testing.T) {
70+
t.Parallel()
71+
72+
cmd := newTestContractCommand(t)
73+
cmd.SetArgs([]string{"verify-env"})
74+
75+
err := cmd.Execute()
76+
77+
require.Error(t, err)
78+
require.Equal(t, `required flag(s) "environment" not set`, err.Error())
79+
}
80+
81+
func TestConfig_Validate(t *testing.T) {
82+
t.Parallel()
83+
84+
tempDir := t.TempDir()
85+
86+
t.Run("missing all required fields", func(t *testing.T) {
87+
t.Parallel()
88+
89+
cfg := Config{}
90+
err := cfg.Validate()
91+
92+
require.Error(t, err)
93+
require.Equal(t, "contract.Config: missing required fields: Logger, Domain, ContractInputsProvider", err.Error())
94+
})
95+
96+
t.Run("missing ContractInputsProvider", func(t *testing.T) {
97+
t.Parallel()
98+
99+
cfg := Config{
100+
Logger: logger.Nop(),
101+
Domain: domain.NewDomain(tempDir, "test"),
102+
}
103+
err := cfg.Validate()
104+
105+
require.EqualError(t, err, "contract.Config: missing required fields: ContractInputsProvider")
106+
})
107+
108+
t.Run("valid config", func(t *testing.T) {
109+
t.Parallel()
110+
111+
cfg := Config{
112+
Logger: logger.Nop(),
113+
Domain: domain.NewDomain(tempDir, "test"),
114+
ContractInputsProvider: &mockContractInputsProvider{},
115+
}
116+
err := cfg.Validate()
117+
118+
require.NoError(t, err)
119+
})
120+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package contract
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
8+
cldcatalog "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/catalog"
9+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config"
10+
cfgdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/domain"
11+
cfgnet "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/network"
12+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
13+
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
14+
)
15+
16+
// NetworkLoaderFunc loads network configuration for an environment.
17+
type NetworkLoaderFunc func(env string, dom domain.Domain) (*cfgnet.Config, error)
18+
19+
// DataStoreLoadOptions configures how the datastore is loaded.
20+
type DataStoreLoadOptions struct {
21+
// FromLocal, when true, always use local files (envdir.DataStore) and ignore domain config.
22+
// Use for local runs when you want to verify against local datastore only.
23+
FromLocal bool
24+
}
25+
26+
// DataStoreLoaderFunc returns a datastore for the given env directory.
27+
// When opts.FromLocal is false and domain uses catalog datastore, loads from the remote catalog (CI-friendly).
28+
type DataStoreLoaderFunc func(ctx context.Context, envdir domain.EnvDir, lggr logger.Logger, opts DataStoreLoadOptions) (datastore.DataStore, error)
29+
30+
// Deps holds injectable dependencies.
31+
type Deps struct {
32+
NetworkLoader NetworkLoaderFunc
33+
DataStoreLoader DataStoreLoaderFunc
34+
}
35+
36+
func (d *Deps) applyDefaults() {
37+
if d.NetworkLoader == nil {
38+
d.NetworkLoader = defaultNetworkLoader
39+
}
40+
if d.DataStoreLoader == nil {
41+
d.DataStoreLoader = defaultDataStoreLoader
42+
}
43+
}
44+
45+
func defaultNetworkLoader(env string, dom domain.Domain) (*cfgnet.Config, error) {
46+
return config.LoadNetworks(env, dom, logger.Nop())
47+
}
48+
49+
func defaultDataStoreLoader(ctx context.Context, envdir domain.EnvDir, lggr logger.Logger, opts DataStoreLoadOptions) (datastore.DataStore, error) {
50+
dom := domain.NewDomain(envdir.RootPath(), envdir.DomainKey())
51+
envKey := envdir.Key()
52+
53+
cfg, err := config.Load(dom, envKey, lggr)
54+
if err != nil {
55+
if opts.FromLocal {
56+
lggr.Infow("Loading datastore from local files")
57+
return envdir.DataStore()
58+
}
59+
60+
return nil, fmt.Errorf("failed to load config: %w", err)
61+
}
62+
63+
if opts.FromLocal || cfg.DatastoreType == cfgdomain.DatastoreTypeFile {
64+
lggr.Infow("Loading datastore from local files")
65+
return envdir.DataStore()
66+
}
67+
68+
if cfg.Env.Catalog.GRPC == "" {
69+
return nil, fmt.Errorf("catalog GRPC endpoint is required when datastore is set to %q", cfg.DatastoreType)
70+
}
71+
lggr.Infow("Loading datastore from catalog", "url", cfg.Env.Catalog.GRPC)
72+
catalogStore, err := cldcatalog.LoadCatalog(ctx, envKey, cfg, dom)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to load catalog: %w", err)
75+
}
76+
ds, err := datastore.LoadDataStoreFromCatalog(ctx, catalogStore)
77+
if err != nil {
78+
return nil, fmt.Errorf("failed to load data from catalog: %w", err)
79+
}
80+
lggr.Infow("Loaded datastore from catalog")
81+
82+
return ds, nil
83+
}

0 commit comments

Comments
 (0)