Skip to content

Commit 00808d0

Browse files
committed
feat(prehook):contract verification check
1 parent 3f8ba39 commit 00808d0

5 files changed

Lines changed: 695 additions & 0 deletions

File tree

.changeset/fair-kiwis-act.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: adds pre hook contract verification check
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Package verification provides pre-hooks that enforce contract verification
2+
// before changeset execution. Use RequireVerified to block changesets when
3+
// referenced contracts must be verified on block explorers first.
4+
package verification
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
"time"
11+
12+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
13+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset"
14+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config"
15+
fdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
16+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification/evm"
17+
)
18+
19+
const hookTimeout = 60 * time.Second
20+
21+
// RequireVerifiedOption configures RequireVerified behavior.
22+
type RequireVerifiedOption func(*requireVerifiedOpts)
23+
24+
type requireVerifiedOpts struct {
25+
httpClient *http.Client
26+
}
27+
28+
// WithHTTPClient sets the HTTP client for block explorer API calls. Use for testing.
29+
func WithHTTPClient(c *http.Client) RequireVerifiedOption {
30+
return func(o *requireVerifiedOpts) {
31+
o.httpClient = c
32+
}
33+
}
34+
35+
// RefsProvider returns the address refs to check for a given changeset.
36+
// Return nil or empty slice to skip verification for that changeset.
37+
type RefsProvider func(params changeset.PreHookParams) ([]datastore.AddressRef, error)
38+
39+
// RefsForChangeset returns a RefsProvider that looks up refs by changeset key.
40+
// Use this when each changeset has a fixed set of contracts to verify.
41+
func RefsForChangeset(m map[string][]datastore.AddressRef) RefsProvider {
42+
return func(params changeset.PreHookParams) ([]datastore.AddressRef, error) {
43+
return m[params.ChangesetKey], nil
44+
}
45+
}
46+
47+
// RequireVerified returns a PreHook that blocks changeset execution when any
48+
// of the provided refs are not verified on block explorers. Uses evm.CheckVerified
49+
// under the hood. Skips when refsProvider returns no refs.
50+
//
51+
// Requires domain (for loading network config), a refsProvider (to get refs per
52+
// changeset), and a ContractInputsProvider (for contract metadata). Uses Abort
53+
// policy with a 60s timeout.
54+
func RequireVerified(
55+
domain fdomain.Domain,
56+
refsProvider RefsProvider,
57+
contractInputsProvider evm.ContractInputsProvider,
58+
opts ...RequireVerifiedOption,
59+
) changeset.PreHook {
60+
var o requireVerifiedOpts
61+
for _, opt := range opts {
62+
opt(&o)
63+
}
64+
return changeset.PreHook{
65+
HookDefinition: changeset.HookDefinition{
66+
Name: "require-verified",
67+
FailurePolicy: changeset.Abort,
68+
Timeout: hookTimeout,
69+
},
70+
Func: func(ctx context.Context, params changeset.PreHookParams) error {
71+
refs, err := refsProvider(params)
72+
if err != nil {
73+
return fmt.Errorf("require-verified: get refs: %w", err)
74+
}
75+
if len(refs) == 0 {
76+
return nil
77+
}
78+
79+
networkCfg, err := config.LoadNetworks(params.Env.Name, domain, params.Env.Logger)
80+
if err != nil {
81+
return fmt.Errorf("require-verified: load networks: %w", err)
82+
}
83+
84+
checkCfg := evm.CheckConfig{
85+
ContractInputsProvider: contractInputsProvider,
86+
NetworkConfig: networkCfg,
87+
Logger: params.Env.Logger,
88+
}
89+
if o.httpClient != nil {
90+
checkCfg.HTTPClient = o.httpClient
91+
}
92+
unverified, err := evm.CheckVerified(ctx, refs, checkCfg)
93+
if err != nil {
94+
return fmt.Errorf("require-verified: %w", err)
95+
}
96+
if len(unverified) > 0 {
97+
return fmt.Errorf("require-verified: %d contract(s) not verified: %v", len(unverified), unverified)
98+
}
99+
return nil
100+
},
101+
}
102+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package verification
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/Masterminds/semver/v3"
14+
"github.com/stretchr/testify/require"
15+
16+
chainsel "github.com/smartcontractkit/chain-selectors"
17+
18+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
19+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset"
20+
fdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain"
21+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification/evm"
22+
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
23+
)
24+
25+
func TestRefsForChangeset(t *testing.T) {
26+
t.Parallel()
27+
28+
ref1 := datastore.AddressRef{Address: "0x1", ChainSelector: 1, Type: "A", Version: semver.MustParse("1.0.0")}
29+
ref2 := datastore.AddressRef{Address: "0x2", ChainSelector: 2, Type: "B", Version: semver.MustParse("2.0.0")}
30+
31+
provider := RefsForChangeset(map[string][]datastore.AddressRef{
32+
"cs-a": {ref1},
33+
"cs-b": {ref2},
34+
"cs-both": {ref1, ref2},
35+
})
36+
37+
t.Run("returns refs for known key", func(t *testing.T) {
38+
t.Parallel()
39+
params := changeset.PreHookParams{ChangesetKey: "cs-a"}
40+
refs, err := provider(params)
41+
require.NoError(t, err)
42+
require.Len(t, refs, 1)
43+
require.Equal(t, "0x1", refs[0].Address)
44+
})
45+
46+
t.Run("returns empty for unknown key", func(t *testing.T) {
47+
t.Parallel()
48+
params := changeset.PreHookParams{ChangesetKey: "unknown"}
49+
refs, err := provider(params)
50+
require.NoError(t, err)
51+
require.Empty(t, refs)
52+
})
53+
54+
t.Run("returns multiple refs", func(t *testing.T) {
55+
t.Parallel()
56+
params := changeset.PreHookParams{ChangesetKey: "cs-both"}
57+
refs, err := provider(params)
58+
require.NoError(t, err)
59+
require.Len(t, refs, 2)
60+
})
61+
}
62+
63+
func TestRequireVerified_EmptyRefsSkips(t *testing.T) {
64+
t.Parallel()
65+
66+
dom := fdomain.NewDomain(t.TempDir(), "test")
67+
provider := RefsForChangeset(map[string][]datastore.AddressRef{
68+
"my-cs": {}, // empty - should skip
69+
})
70+
hook := RequireVerified(dom, provider, &mockContractInputsProvider{})
71+
72+
params := changeset.PreHookParams{
73+
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
74+
ChangesetKey: "my-cs",
75+
}
76+
err := hook.Func(t.Context(), params)
77+
require.NoError(t, err)
78+
}
79+
80+
func TestRequireVerified_RefsProviderError(t *testing.T) {
81+
t.Parallel()
82+
83+
dom := fdomain.NewDomain(t.TempDir(), "test")
84+
provider := func(changeset.PreHookParams) ([]datastore.AddressRef, error) {
85+
return nil, errors.New("refs lookup failed")
86+
}
87+
hook := RequireVerified(dom, provider, &mockContractInputsProvider{})
88+
89+
params := changeset.PreHookParams{
90+
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
91+
ChangesetKey: "my-cs",
92+
}
93+
err := hook.Func(t.Context(), params)
94+
require.Error(t, err)
95+
require.Contains(t, err.Error(), "require-verified: get refs")
96+
require.Contains(t, err.Error(), "refs lookup failed")
97+
}
98+
99+
func TestRequireVerified_HookDefinition(t *testing.T) {
100+
t.Parallel()
101+
102+
dom := fdomain.NewDomain(t.TempDir(), "test")
103+
hook := RequireVerified(dom, RefsForChangeset(map[string][]datastore.AddressRef{}), &mockContractInputsProvider{})
104+
105+
require.Equal(t, "require-verified", hook.Name)
106+
require.Equal(t, changeset.Abort, hook.FailurePolicy)
107+
require.NotZero(t, hook.Timeout)
108+
}
109+
110+
func TestRequireVerified_FullFlow_AllVerified(t *testing.T) {
111+
t.Parallel()
112+
113+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
114+
w.Header().Set("Content-Type", "application/json")
115+
_ = json.NewEncoder(w).Encode(map[string]string{
116+
"status": "1",
117+
"message": "OK",
118+
"result": `[{"type":"constructor"}]`,
119+
})
120+
}))
121+
defer server.Close()
122+
123+
targetURL, _ := url.Parse(server.URL)
124+
httpClient := &http.Client{Transport: &redirectTransport{target: targetURL}}
125+
126+
dom := setupDomainWithNetworks(t)
127+
ethSelector := chainsel.ETHEREUM_MAINNET.Selector
128+
refs := []datastore.AddressRef{
129+
{Address: "0xVerified", ChainSelector: ethSelector, Type: "LinkToken", Version: semver.MustParse("1.0.0")},
130+
}
131+
provider := RefsForChangeset(map[string][]datastore.AddressRef{"my-cs": refs})
132+
hook := RequireVerified(dom, provider, &mockContractInputsProvider{}, WithHTTPClient(httpClient))
133+
134+
params := changeset.PreHookParams{
135+
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
136+
ChangesetKey: "my-cs",
137+
}
138+
err := hook.Func(t.Context(), params)
139+
require.NoError(t, err)
140+
}
141+
142+
func TestRequireVerified_FullFlow_UnverifiedFails(t *testing.T) {
143+
t.Parallel()
144+
145+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
146+
w.Header().Set("Content-Type", "application/json")
147+
_ = json.NewEncoder(w).Encode(map[string]string{
148+
"status": "1",
149+
"message": "OK",
150+
"result": "Contract source code not verified",
151+
})
152+
}))
153+
defer server.Close()
154+
155+
targetURL, _ := url.Parse(server.URL)
156+
httpClient := &http.Client{Transport: &redirectTransport{target: targetURL}}
157+
158+
dom := setupDomainWithNetworks(t)
159+
ethSelector := chainsel.ETHEREUM_MAINNET.Selector
160+
refs := []datastore.AddressRef{
161+
{Address: "0xUnverified", ChainSelector: ethSelector, Type: "LinkToken", Version: semver.MustParse("1.0.0")},
162+
}
163+
provider := RefsForChangeset(map[string][]datastore.AddressRef{"my-cs": refs})
164+
hook := RequireVerified(dom, provider, &mockContractInputsProvider{}, WithHTTPClient(httpClient))
165+
166+
params := changeset.PreHookParams{
167+
Env: changeset.HookEnv{Name: "staging", Logger: logger.Nop()},
168+
ChangesetKey: "my-cs",
169+
}
170+
err := hook.Func(t.Context(), params)
171+
require.Error(t, err)
172+
require.Contains(t, err.Error(), "require-verified:")
173+
require.Contains(t, err.Error(), "not verified")
174+
}
175+
176+
// setupDomainWithNetworks creates a domain with .config/domain.yaml and .config/networks/*.yaml
177+
// so config.LoadNetworks works.
178+
func setupDomainWithNetworks(t *testing.T) fdomain.Domain {
179+
t.Helper()
180+
181+
rootDir := t.TempDir()
182+
domainKey := "test-domain"
183+
domainDir := filepath.Join(rootDir, domainKey)
184+
configDir := filepath.Join(domainDir, ".config")
185+
networksDir := filepath.Join(configDir, "networks")
186+
187+
require.NoError(t, os.MkdirAll(networksDir, 0700))
188+
189+
domainYAML := `environments:
190+
staging:
191+
network_types:
192+
- mainnet
193+
`
194+
require.NoError(t, os.WriteFile(filepath.Join(configDir, "domain.yaml"), []byte(domainYAML), 0600))
195+
196+
// Ethereum mainnet with block explorer - etherscan needs API key for NewVerifier
197+
networksYAML := `networks:
198+
- type: mainnet
199+
chain_selector: 5009297550715157269
200+
block_explorer:
201+
type: Etherscan
202+
api_key: test-key
203+
url: https://etherscan.io
204+
rpcs:
205+
- rpc_name: mainnet-rpc
206+
preferred_url_scheme: http
207+
http_url: https://eth.llamarpc.com
208+
ws_url: wss://eth.llamarpc.com
209+
`
210+
require.NoError(t, os.WriteFile(filepath.Join(networksDir, "networks-mainnet.yaml"), []byte(networksYAML), 0600))
211+
212+
return fdomain.NewDomain(rootDir, domainKey)
213+
}
214+
215+
// redirectTransport redirects HTTP requests to a target URL for testing.
216+
type redirectTransport struct {
217+
target *url.URL
218+
}
219+
220+
func (r *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
221+
req.URL.Scheme = r.target.Scheme
222+
req.URL.Host = r.target.Host
223+
224+
return http.DefaultTransport.RoundTrip(req)
225+
}
226+
227+
// mockContractInputsProvider is a test double for evm.ContractInputsProvider.
228+
type mockContractInputsProvider struct{}
229+
230+
func (m *mockContractInputsProvider) GetInputs(_ datastore.ContractType, _ *semver.Version) (evm.SolidityContractMetadata, error) {
231+
return evm.SolidityContractMetadata{
232+
Name: "Test",
233+
Version: "0.8.19",
234+
Sources: map[string]any{"test.sol": map[string]any{"content": "contract Test {}"}},
235+
}, nil
236+
}

0 commit comments

Comments
 (0)