Skip to content
Draft
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/fair-kiwis-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": minor
---

feat: adds pre hook contract verification check
104 changes: 104 additions & 0 deletions engine/cld/hooks/verification/require_verified.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Package verification provides pre-hooks that enforce contract verification
// before changeset execution. Use RequireVerified to block changesets when
// referenced contracts must be verified on block explorers first.
package verification

import (
"context"
"fmt"
"net/http"
"time"

"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset"
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config"
fdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification/evm"
)

const hookTimeout = 60 * time.Second

// RequireVerifiedOption configures RequireVerified behavior.
type RequireVerifiedOption func(*requireVerifiedOpts)

type requireVerifiedOpts struct {
httpClient *http.Client
}

// WithHTTPClient sets the HTTP client for block explorer API calls. Use for testing.
func WithHTTPClient(c *http.Client) RequireVerifiedOption {
return func(o *requireVerifiedOpts) {
o.httpClient = c
}
}

// RefsProvider returns the address refs to check for a given changeset.
// Return nil or empty slice to skip verification for that changeset.
type RefsProvider func(params changeset.PreHookParams) ([]datastore.AddressRef, error)

// RefsForChangeset returns a RefsProvider that looks up refs by changeset key.
// Use this when each changeset has a fixed set of contracts to verify.
func RefsForChangeset(m map[string][]datastore.AddressRef) RefsProvider {
return func(params changeset.PreHookParams) ([]datastore.AddressRef, error) {
return m[params.ChangesetKey], nil
}
}

// RequireVerified returns a PreHook that blocks changeset execution when any
// of the provided refs are not verified on block explorers. Uses evm.CheckVerified
// under the hood. Skips when refsProvider returns no refs.
//
// Requires domain (for loading network config), a refsProvider (to get refs per
// changeset), and a ContractInputsProvider (for contract metadata). Uses Abort
// policy with a 60s timeout.
func RequireVerified(
domain fdomain.Domain,
refsProvider RefsProvider,
contractInputsProvider evm.ContractInputsProvider,
opts ...RequireVerifiedOption,
) changeset.PreHook {
var o requireVerifiedOpts
for _, opt := range opts {
opt(&o)
}

return changeset.PreHook{
HookDefinition: changeset.HookDefinition{
Name: "require-verified",
FailurePolicy: changeset.Abort,
Timeout: hookTimeout,
},
Func: func(ctx context.Context, params changeset.PreHookParams) error {
refs, err := refsProvider(params)
if err != nil {
return fmt.Errorf("require-verified: get refs: %w", err)
}
if len(refs) == 0 {
return nil
}

networkCfg, err := config.LoadNetworks(params.Env.Name, domain, params.Env.Logger)
if err != nil {
return fmt.Errorf("require-verified: load networks: %w", err)
}

checkCfg := evm.CheckConfig{
ContractInputsProvider: contractInputsProvider,
NetworkConfig: networkCfg,
Logger: params.Env.Logger,
}
if o.httpClient != nil {
checkCfg.HTTPClient = o.httpClient
}
unverified, err := evm.CheckVerified(ctx, refs, checkCfg)
if err != nil {
return fmt.Errorf("require-verified: %w", err)
}
if len(unverified) > 0 {
return fmt.Errorf("require-verified: %d contract(s) not verified: %v", len(unverified), unverified)
}

return nil
},
}
}
236 changes: 236 additions & 0 deletions engine/cld/hooks/verification/require_verified_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package verification

import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"

"github.com/Masterminds/semver/v3"
"github.com/stretchr/testify/require"

chainsel "github.com/smartcontractkit/chain-selectors"

"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset"
fdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification/evm"
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
)

func TestRefsForChangeset(t *testing.T) {
t.Parallel()

ref1 := datastore.AddressRef{Address: "0x1", ChainSelector: 1, Type: "A", Version: semver.MustParse("1.0.0")}
ref2 := datastore.AddressRef{Address: "0x2", ChainSelector: 2, Type: "B", Version: semver.MustParse("2.0.0")}

provider := RefsForChangeset(map[string][]datastore.AddressRef{
"cs-a": {ref1},
"cs-b": {ref2},
"cs-both": {ref1, ref2},
})

t.Run("returns refs for known key", func(t *testing.T) {
t.Parallel()
params := changeset.PreHookParams{ChangesetKey: "cs-a"}
refs, err := provider(params)
require.NoError(t, err)
require.Len(t, refs, 1)
require.Equal(t, "0x1", refs[0].Address)
})

t.Run("returns empty for unknown key", func(t *testing.T) {
t.Parallel()
params := changeset.PreHookParams{ChangesetKey: "unknown"}
refs, err := provider(params)
require.NoError(t, err)
require.Empty(t, refs)
})

t.Run("returns multiple refs", func(t *testing.T) {
t.Parallel()
params := changeset.PreHookParams{ChangesetKey: "cs-both"}
refs, err := provider(params)
require.NoError(t, err)
require.Len(t, refs, 2)
})
}

func TestRequireVerified_EmptyRefsSkips(t *testing.T) {
t.Parallel()

dom := fdomain.NewDomain(t.TempDir(), "test")
provider := RefsForChangeset(map[string][]datastore.AddressRef{
"my-cs": {}, // empty - should skip
})
hook := RequireVerified(dom, provider, &mockContractInputsProvider{})

params := changeset.PreHookParams{
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
ChangesetKey: "my-cs",
}
err := hook.Func(t.Context(), params)
require.NoError(t, err)
}

func TestRequireVerified_RefsProviderError(t *testing.T) {
t.Parallel()

dom := fdomain.NewDomain(t.TempDir(), "test")
provider := func(changeset.PreHookParams) ([]datastore.AddressRef, error) {
return nil, errors.New("refs lookup failed")
}
hook := RequireVerified(dom, provider, &mockContractInputsProvider{})

params := changeset.PreHookParams{
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
ChangesetKey: "my-cs",
}
err := hook.Func(t.Context(), params)
require.Error(t, err)
require.Contains(t, err.Error(), "require-verified: get refs")
require.Contains(t, err.Error(), "refs lookup failed")
}

func TestRequireVerified_HookDefinition(t *testing.T) {
t.Parallel()

dom := fdomain.NewDomain(t.TempDir(), "test")
hook := RequireVerified(dom, RefsForChangeset(map[string][]datastore.AddressRef{}), &mockContractInputsProvider{})

require.Equal(t, "require-verified", hook.Name)
require.Equal(t, changeset.Abort, hook.FailurePolicy)
require.NotZero(t, hook.Timeout)
}

func TestRequireVerified_FullFlow_AllVerified(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "1",
"message": "OK",
"result": `[{"type":"constructor"}]`,
})
}))
defer server.Close()

targetURL, _ := url.Parse(server.URL)
httpClient := &http.Client{Transport: &redirectTransport{target: targetURL}}

dom := setupDomainWithNetworks(t)
ethSelector := chainsel.ETHEREUM_MAINNET.Selector
refs := []datastore.AddressRef{
{Address: "0xVerified", ChainSelector: ethSelector, Type: "LinkToken", Version: semver.MustParse("1.0.0")},
}
provider := RefsForChangeset(map[string][]datastore.AddressRef{"my-cs": refs})
hook := RequireVerified(dom, provider, &mockContractInputsProvider{}, WithHTTPClient(httpClient))

params := changeset.PreHookParams{
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
ChangesetKey: "my-cs",
}
err := hook.Func(t.Context(), params)
require.NoError(t, err)
}

func TestRequireVerified_FullFlow_UnverifiedFails(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "1",
"message": "OK",
"result": "Contract source code not verified",
})
}))
defer server.Close()

targetURL, _ := url.Parse(server.URL)
httpClient := &http.Client{Transport: &redirectTransport{target: targetURL}}

dom := setupDomainWithNetworks(t)
ethSelector := chainsel.ETHEREUM_MAINNET.Selector
refs := []datastore.AddressRef{
{Address: "0xUnverified", ChainSelector: ethSelector, Type: "LinkToken", Version: semver.MustParse("1.0.0")},
}
provider := RefsForChangeset(map[string][]datastore.AddressRef{"my-cs": refs})
hook := RequireVerified(dom, provider, &mockContractInputsProvider{}, WithHTTPClient(httpClient))

params := changeset.PreHookParams{
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
ChangesetKey: "my-cs",
}
err := hook.Func(t.Context(), params)
require.Error(t, err)
require.Contains(t, err.Error(), "require-verified:")
require.Contains(t, err.Error(), "not verified")
}

// setupDomainWithNetworks creates a domain with .config/domain.yaml and .config/networks/*.yaml
// so config.LoadNetworks works.
func setupDomainWithNetworks(t *testing.T) fdomain.Domain {
t.Helper()

rootDir := t.TempDir()
domainKey := "test-domain"
domainDir := filepath.Join(rootDir, domainKey)
configDir := filepath.Join(domainDir, ".config")
networksDir := filepath.Join(configDir, "networks")

require.NoError(t, os.MkdirAll(networksDir, 0700))

domainYAML := `environments:
staging:
network_types:
- mainnet
`
require.NoError(t, os.WriteFile(filepath.Join(configDir, "domain.yaml"), []byte(domainYAML), 0600))

// Ethereum mainnet with block explorer - etherscan needs API key for NewVerifier
networksYAML := `networks:
- type: mainnet
chain_selector: 5009297550715157269
block_explorer:
type: Etherscan
api_key: test-key
url: https://etherscan.io
rpcs:
- rpc_name: mainnet-rpc
preferred_url_scheme: http
http_url: https://eth.llamarpc.com
ws_url: wss://eth.llamarpc.com
`
require.NoError(t, os.WriteFile(filepath.Join(networksDir, "networks-mainnet.yaml"), []byte(networksYAML), 0600))

return fdomain.NewDomain(rootDir, domainKey)
}

// redirectTransport redirects HTTP requests to a target URL for testing.
type redirectTransport struct {
target *url.URL
}

func (r *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.URL.Scheme = r.target.Scheme
req.URL.Host = r.target.Host

return http.DefaultTransport.RoundTrip(req)
}

// mockContractInputsProvider is a test double for evm.ContractInputsProvider.
type mockContractInputsProvider struct{}

func (m *mockContractInputsProvider) GetInputs(_ datastore.ContractType, _ *semver.Version) (evm.SolidityContractMetadata, error) {
return evm.SolidityContractMetadata{
Name: "Test",
Version: "0.8.19",
Sources: map[string]any{"test.sol": map[string]any{"content": "contract Test {}"}},
}, nil
}
Loading
Loading