diff --git a/engine/cld/verification/evm/factory.go b/engine/cld/verification/evm/factory.go index c9a12733..6743caf5 100644 --- a/engine/cld/verification/evm/factory.go +++ b/engine/cld/verification/evm/factory.go @@ -48,7 +48,7 @@ func NewVerifier(strategy VerificationStrategy, cfg VerifierConfig) (verificatio case StrategyBlockscout: return newBlockscoutVerifier(cfg) case StrategySourcify: - return nil, errors.New("sourcify verifier not yet implemented") + return newSourcifyVerifier(cfg) case StrategyOkLink: return nil, errors.New("oklink verifier not yet implemented") case StrategyBtrScan: diff --git a/engine/cld/verification/evm/factory_test.go b/engine/cld/verification/evm/factory_test.go index d41a74dc..e89ba5ed 100644 --- a/engine/cld/verification/evm/factory_test.go +++ b/engine/cld/verification/evm/factory_test.go @@ -91,7 +91,7 @@ func TestNewVerifier_StrategyBlockscout(t *testing.T) { require.Equal(t, "chain ID 1 is not supported by the Blockscout API", err.Error()) } -func TestNewVerifier_StrategySourcify(t *testing.T) { +func TestNewVerifier_StrategySourcify_UnsupportedChain(t *testing.T) { t.Parallel() chain, ok := chainsel.ChainBySelector(chainsel.ETHEREUM_MAINNET.Selector) @@ -107,7 +107,34 @@ func TestNewVerifier_StrategySourcify(t *testing.T) { Logger: logger.Nop(), }) require.Error(t, err) - require.Equal(t, "sourcify verifier not yet implemented", err.Error()) + require.Contains(t, err.Error(), "not supported by Sourcify") +} + +func TestNewVerifier_StrategySourcify(t *testing.T) { + t.Parallel() + + chain := chainsel.Chain{ + EvmChainID: 295, + Selector: chainsel.HEDERA_MAINNET.Selector, + Name: "hedera-mainnet", + } + + v, err := NewVerifier(StrategySourcify, VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector}, + Address: "0x123", + Metadata: SolidityContractMetadata{ + Version: "0.8.19", + Language: "Solidity", + Name: "Test", + }, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + }) + require.NoError(t, err) + require.NotNil(t, v) + require.Equal(t, "Test 1.0.0 (0x123 on hedera-mainnet)", v.String()) } func TestNewVerifier_StrategyRoutescan(t *testing.T) { diff --git a/engine/cld/verification/evm/sourcify.go b/engine/cld/verification/evm/sourcify.go index 544ceaf6..1b3589f8 100644 --- a/engine/cld/verification/evm/sourcify.go +++ b/engine/cld/verification/evm/sourcify.go @@ -1,10 +1,24 @@ package evm var sourcifyChainIDs = map[uint64]struct{}{ - 295: {}, 296: {}, 2020: {}, 2021: {}, + 295: {}, 296: {}, 2020: {}, 2021: {}, 4217: {}, 42431: {}, +} + +// sourcifyCustomServerURLs maps chain IDs to custom Sourcify-compatible server URLs. +// Chains not listed here fall back to the default sourcifyServerURL. +var sourcifyCustomServerURLs = map[uint64]string{ + 4217: "https://contracts.tempo.xyz", + 42431: "https://contracts.tempo.xyz", } func IsChainSupportedOnSourcify(chainID uint64) bool { _, ok := sourcifyChainIDs[chainID] return ok } + +func getSourcifyServerURL(chainID uint64) string { + if url, ok := sourcifyCustomServerURLs[chainID]; ok { + return url + } + return sourcifyServerURL +} diff --git a/engine/cld/verification/evm/sourcify_verifier.go b/engine/cld/verification/evm/sourcify_verifier.go new file mode 100644 index 00000000..7906a84c --- /dev/null +++ b/engine/cld/verification/evm/sourcify_verifier.go @@ -0,0 +1,306 @@ +package evm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/verification" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +const sourcifyServerURL = "https://sourcify.dev/server" + +func newSourcifyVerifier(cfg VerifierConfig) (verification.Verifiable, error) { + if !IsChainSupportedOnSourcify(cfg.Chain.EvmChainID) { + return nil, fmt.Errorf("chain ID %d is not supported by Sourcify", cfg.Chain.EvmChainID) + } + + baseURL := getSourcifyServerURL(cfg.Chain.EvmChainID) + if cfg.Network.BlockExplorer.URL != "" { + baseURL = strings.TrimSuffix(cfg.Network.BlockExplorer.URL, "/") + } + + return &sourcifyVerifier{ + chain: cfg.Chain, + baseURL: baseURL, + address: cfg.Address, + metadata: cfg.Metadata, + contractType: cfg.ContractType, + version: cfg.Version, + pollInterval: cfg.PollInterval, + lggr: cfg.Logger, + httpClient: cfg.HTTPClient, + }, nil +} + +type sourcifyVerifier struct { + chain chainsel.Chain + baseURL string + address string + metadata SolidityContractMetadata + contractType string + version string + pollInterval time.Duration + lggr logger.Logger + httpClient *http.Client +} + +func (v *sourcifyVerifier) String() string { + return fmt.Sprintf("%s %s (%s on %s)", v.contractType, v.version, v.address, v.chain.Name) +} + +func (v *sourcifyVerifier) client() *http.Client { + if v.httpClient != nil { + return v.httpClient + } + return http.DefaultClient +} + +// IsVerified checks whether the contract is already verified on Sourcify via +// GET /v2/contract/{chainId}/{address}. +// A 200 with a non-null "match" field means verified; 404 means not verified. +func (v *sourcifyVerifier) IsVerified(ctx context.Context) (bool, error) { + url := fmt.Sprintf("%s/v2/contract/%d/%s", v.baseURL, v.chain.EvmChainID, v.address) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + + resp, err := v.client().Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return false, fmt.Errorf("sourcify IsVerified: unexpected status %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + + var result sourcifyContractResponse + if err := json.Unmarshal(body, &result); err != nil { + return false, fmt.Errorf("failed to decode sourcify response: %w", err) + } + + if result.Match != nil { + v.lggr.Infof("Contract %s is already verified on Sourcify (match=%s)", v.address, *result.Match) + return true, nil + } + + return false, nil +} + +// Verify submits the contract for verification on Sourcify and polls until completion. +func (v *sourcifyVerifier) Verify(ctx context.Context) error { + verified, err := v.IsVerified(ctx) + if err != nil { + return fmt.Errorf("failed to check verification status: %w", err) + } + if verified { + v.lggr.Infof("%s is already verified", v.String()) + return nil + } + + verificationID, err := v.submitVerification(ctx) + if err != nil { + return err + } + + v.lggr.Infof("Verification submitted for %s, verificationId=%s", v.String(), verificationID) + + return v.pollVerification(ctx, verificationID) +} + +func (v *sourcifyVerifier) submitVerification(ctx context.Context) (string, error) { + stdJsonInput := map[string]any{ + "language": v.metadata.Language, + "settings": v.metadata.Settings, + "sources": v.metadata.Sources, + } + + contractID := v.buildContractIdentifier() + + reqBody := sourcifyVerifyRequest{ + StdJsonInput: stdJsonInput, + CompilerVersion: v.metadata.Version, + ContractIdentifier: contractID, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal verify request: %w", err) + } + + url := fmt.Sprintf("%s/v2/verify/%d/%s", v.baseURL, v.chain.EvmChainID, v.address) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := v.client().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read verify response: %w", err) + } + + if resp.StatusCode == http.StatusConflict { + v.lggr.Infof("%s is already verified on Sourcify (409 conflict)", v.String()) + return "", nil + } + + if resp.StatusCode != http.StatusAccepted { + return "", fmt.Errorf("sourcify verify: unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var verifyResp sourcifyVerifyResponse + if err := json.Unmarshal(body, &verifyResp); err != nil { + return "", fmt.Errorf("failed to decode verify response: %w", err) + } + + return verifyResp.VerificationID, nil +} + +func (v *sourcifyVerifier) pollVerification(ctx context.Context, verificationID string) error { + if verificationID == "" { + return nil + } + + pollDur := v.pollInterval + if pollDur <= 0 { + pollDur = 5 * time.Second + } + + url := fmt.Sprintf("%s/v2/verify/%s", v.baseURL, verificationID) + + for range maxVerificationPollAttempts { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := v.client().Do(req) + if err != nil { + return fmt.Errorf("failed to poll verification status: %w", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("failed to read poll response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("sourcify poll: unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var status sourcifyJobStatus + if err := json.Unmarshal(body, &status); err != nil { + return fmt.Errorf("failed to decode poll response: %w", err) + } + + if !status.IsJobCompleted { + v.lggr.Infof("Verification in progress for %s, checking again in %s", v.String(), pollDur) + select { + case <-time.After(pollDur): + case <-ctx.Done(): + return ctx.Err() + } + continue + } + + if status.Error != nil { + return fmt.Errorf("sourcify verification failed: %s - %s", status.Error.CustomCode, status.Error.Message) + } + + if status.Contract != nil && status.Contract.Match != nil { + v.lggr.Infof("Verification succeeded for %s (match=%s)", v.String(), *status.Contract.Match) + return nil + } + + return fmt.Errorf("sourcify verification completed but contract match is empty") + } + + return fmt.Errorf("verification timed out after %d attempts", maxVerificationPollAttempts) +} + +// buildContractIdentifier returns the Sourcify contractIdentifier in "path/to/File.sol:ContractName" format. +// If metadata.Name is already fully qualified (contains ":"), it is used as-is. +// Otherwise we attempt to locate the matching source file in the sources map. +func (v *sourcifyVerifier) buildContractIdentifier() string { + name := v.metadata.Name + if name == "" { + name = v.contractType + } + + if strings.Contains(name, ":") { + return name + } + + suffix := "/" + name + ".sol" + for source := range v.metadata.Sources { + if strings.HasSuffix(source, suffix) { + return source + ":" + name + } + } + for source := range v.metadata.Sources { + if strings.HasSuffix(source, name+".sol") { + return source + ":" + name + } + } + for source := range v.metadata.Sources { + return source + ":" + name + } + + return name +} + +// Sourcify API types + +type sourcifyVerifyRequest struct { + StdJsonInput map[string]any `json:"stdJsonInput"` + CompilerVersion string `json:"compilerVersion"` + ContractIdentifier string `json:"contractIdentifier"` +} + +type sourcifyVerifyResponse struct { + VerificationID string `json:"verificationId"` +} + +type sourcifyContractResponse struct { + Match *string `json:"match"` +} + +type sourcifyJobStatus struct { + IsJobCompleted bool `json:"isJobCompleted"` + VerificationID string `json:"verificationId"` + Error *sourcifyJobError `json:"error"` + Contract *sourcifyContractResponse `json:"contract"` +} + +type sourcifyJobError struct { + CustomCode string `json:"customCode"` + Message string `json:"message"` +} diff --git a/engine/cld/verification/evm/sourcify_verifier_test.go b/engine/cld/verification/evm/sourcify_verifier_test.go new file mode 100644 index 00000000..f03cd150 --- /dev/null +++ b/engine/cld/verification/evm/sourcify_verifier_test.go @@ -0,0 +1,500 @@ +package evm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cfgnet "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/config/network" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func newTestSourcifyChain() chainsel.Chain { + return chainsel.Chain{ + EvmChainID: 295, + Selector: chainsel.HEDERA_MAINNET.Selector, + Name: "hedera-mainnet", + } +} + +func TestSourcifyVerifier_IsVerified_AlreadyVerified(t *testing.T) { + t.Parallel() + + match := "exact_match" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/v2/contract/295/0xabc") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(sourcifyContractResponse{Match: &match}) + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{ + Name: "Test", + Version: "0.8.19", + Sources: map[string]any{"Test.sol": map[string]any{"content": "contract Test {}"}}, + }, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + verified, err := v.(*sourcifyVerifier).IsVerified(context.Background()) + require.NoError(t, err) + require.True(t, verified) +} + +func TestSourcifyVerifier_IsVerified_NotVerified(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{Name: "Test", Version: "0.8.19"}, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + verified, err := v.(*sourcifyVerifier).IsVerified(context.Background()) + require.NoError(t, err) + require.False(t, verified) +} + +func TestSourcifyVerifier_IsVerified_NullMatch(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(sourcifyContractResponse{Match: nil}) + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{Name: "Test", Version: "0.8.19"}, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + verified, err := v.(*sourcifyVerifier).IsVerified(context.Background()) + require.NoError(t, err) + require.False(t, verified) +} + +func TestSourcifyVerifier_Verify_AlreadyVerified(t *testing.T) { + t.Parallel() + + match := "match" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(sourcifyContractResponse{Match: &match}) + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{ + Name: "Test", + Version: "0.8.19", + Language: "Solidity", + Sources: map[string]any{"Test.sol": map[string]any{"content": "contract Test {}"}}, + }, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.NoError(t, err) +} + +func TestSourcifyVerifier_Verify_SubmitAndPoll(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + match := "exact_match" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callNum := calls.Add(1) + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && callNum == 1: + // IsVerified check -> not found + w.WriteHeader(http.StatusNotFound) + + case r.Method == http.MethodPost: + var body sourcifyVerifyRequest + _ = json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "0.8.19", body.CompilerVersion) + assert.Equal(t, "contracts/Test.sol:Test", body.ContractIdentifier) + assert.NotNil(t, body.StdJsonInput) + assert.Equal(t, "Solidity", body.StdJsonInput["language"]) + + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(sourcifyVerifyResponse{VerificationID: "test-uuid-123"}) + + case r.Method == http.MethodGet && callNum == 3: + // First poll -> still processing + _ = json.NewEncoder(w).Encode(sourcifyJobStatus{ + IsJobCompleted: false, + VerificationID: "test-uuid-123", + }) + + case r.Method == http.MethodGet && callNum == 4: + // Second poll -> done + _ = json.NewEncoder(w).Encode(sourcifyJobStatus{ + IsJobCompleted: true, + VerificationID: "test-uuid-123", + Contract: &sourcifyContractResponse{Match: &match}, + }) + } + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{ + Name: "contracts/Test.sol:Test", + Version: "0.8.19", + Language: "Solidity", + Sources: map[string]any{"contracts/Test.sol": map[string]any{"content": "contract Test {}"}}, + Settings: map[string]any{"optimizer": map[string]any{"enabled": false}}, + }, + ContractType: "Test", + Version: "1.0.0", + PollInterval: 1, // 1ns for fast tests + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.NoError(t, err) +} + +func TestSourcifyVerifier_Verify_Conflict(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callNum := calls.Add(1) + w.Header().Set("Content-Type", "application/json") + + if callNum == 1 { + // IsVerified -> not found + w.WriteHeader(http.StatusNotFound) + return + } + // Submit -> 409 already verified + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]string{ + "customCode": "already_verified", + "message": "Already verified", + }) + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{ + Name: "Test", + Version: "0.8.19", + Language: "Solidity", + Sources: map[string]any{"Test.sol": map[string]any{"content": "contract Test {}"}}, + }, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.NoError(t, err) +} + +func TestSourcifyVerifier_Verify_Failed(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callNum := calls.Add(1) + w.Header().Set("Content-Type", "application/json") + + switch { + case callNum == 1: + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(sourcifyVerifyResponse{VerificationID: "fail-uuid"}) + default: + _ = json.NewEncoder(w).Encode(sourcifyJobStatus{ + IsJobCompleted: true, + VerificationID: "fail-uuid", + Error: &sourcifyJobError{ + CustomCode: "no_match", + Message: "The onchain and recompiled bytecodes don't match.", + }, + }) + } + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{ + Name: "Test", + Version: "0.8.19", + Language: "Solidity", + Sources: map[string]any{"Test.sol": map[string]any{"content": "contract Test {}"}}, + }, + ContractType: "Test", + Version: "1.0.0", + PollInterval: 1, + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "no_match") +} + +func TestSourcifyVerifier_String(t *testing.T) { + t.Parallel() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector}, + Address: "0xabc", + Metadata: SolidityContractMetadata{}, + ContractType: "MyContract", + Version: "2.0.0", + Logger: logger.Nop(), + }) + require.NoError(t, err) + require.Equal(t, "MyContract 2.0.0 (0xabc on hedera-mainnet)", v.String()) +} + +func TestSourcifyVerifier_BuildContractIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metadata SolidityContractMetadata + expected string + }{ + { + name: "fully qualified name used as-is", + metadata: SolidityContractMetadata{ + Name: "contracts/pools/BurnMintTokenPool.sol:BurnMintTokenPool", + Sources: map[string]any{ + "contracts/pools/BurnMintTokenPool.sol": map[string]any{"content": "..."}, + "node_modules/@openzeppelin/contracts/access/AccessControl.sol": map[string]any{"content": "..."}, + }, + }, + expected: "contracts/pools/BurnMintTokenPool.sol:BurnMintTokenPool", + }, + { + name: "fully qualified name with different source path", + metadata: SolidityContractMetadata{ + Name: "src/v0.8/shared/test/helpers/BurnMintERC20WithDrip.sol:BurnMintERC20WithDrip", + Sources: map[string]any{ + "src/v0.8/shared/test/helpers/BurnMintERC20WithDrip.sol": map[string]any{"content": "..."}, + }, + }, + expected: "src/v0.8/shared/test/helpers/BurnMintERC20WithDrip.sol:BurnMintERC20WithDrip", + }, + { + name: "plain name matched from sources", + metadata: SolidityContractMetadata{ + Name: "Storage", + Sources: map[string]any{"contracts/Storage.sol": map[string]any{"content": "..."}}, + }, + expected: "contracts/Storage.sol:Storage", + }, + { + name: "fallback to contract type when name empty", + metadata: SolidityContractMetadata{ + Sources: map[string]any{"src/Foo.sol": map[string]any{"content": "..."}}, + }, + expected: "src/Foo.sol:Test", + }, + { + name: "name only when no sources", + metadata: SolidityContractMetadata{ + Name: "Standalone", + Sources: map[string]any{}, + }, + expected: "Standalone", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + chain := newTestSourcifyChain() + sv := &sourcifyVerifier{ + chain: chain, + metadata: tt.metadata, + contractType: "Test", + } + got := sv.buildContractIdentifier() + require.Equal(t, tt.expected, got) + }) + } +} + +func TestSourcifyVerifier_CustomBaseURL(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/v2/contract/295/0xabc") + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: server.URL}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{Name: "Test", Version: "0.8.19"}, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + verified, err := v.(*sourcifyVerifier).IsVerified(context.Background()) + require.NoError(t, err) + require.False(t, verified) +} + +func TestSourcifyVerifier_DefaultBaseURL(t *testing.T) { + t.Parallel() + + chain := newTestSourcifyChain() + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector}, + Address: "0xabc", + Metadata: SolidityContractMetadata{}, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + }) + require.NoError(t, err) + require.Equal(t, sourcifyServerURL, v.(*sourcifyVerifier).baseURL) +} + +func TestSourcifyVerifier_TempoCustomURL(t *testing.T) { + t.Parallel() + + chain := chainsel.Chain{ + EvmChainID: 42431, + Selector: 1, + Name: "tempo-testnet-moderato", + } + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector}, + Address: "0xabc", + Metadata: SolidityContractMetadata{}, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + }) + require.NoError(t, err) + require.Equal(t, "https://contracts.tempo.xyz", v.(*sourcifyVerifier).baseURL) +} + +func TestSourcifyVerifier_TempoMainnetCustomURL(t *testing.T) { + t.Parallel() + + chain := chainsel.Chain{ + EvmChainID: 4217, + Selector: 7281642695469137430, + Name: "tempo-mainnet", + } + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector}, + Address: "0xabc", + Metadata: SolidityContractMetadata{}, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + }) + require.NoError(t, err) + require.Equal(t, "https://contracts.tempo.xyz", v.(*sourcifyVerifier).baseURL) +} + +func TestSourcifyVerifier_BlockExplorerURLOverridesCustom(t *testing.T) { + t.Parallel() + + chain := chainsel.Chain{ + EvmChainID: 42431, + Selector: 1, + Name: "tempo-testnet-moderato", + } + v, err := newSourcifyVerifier(VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: "https://custom.sourcify.example"}}, + Address: "0xabc", + Metadata: SolidityContractMetadata{}, + ContractType: "Test", + Version: "1.0.0", + Logger: logger.Nop(), + }) + require.NoError(t, err) + require.Equal(t, "https://custom.sourcify.example", v.(*sourcifyVerifier).baseURL) +} diff --git a/engine/cld/verification/evm/strategy_test.go b/engine/cld/verification/evm/strategy_test.go index 8b2ef4ac..9404a653 100644 --- a/engine/cld/verification/evm/strategy_test.go +++ b/engine/cld/verification/evm/strategy_test.go @@ -33,6 +33,8 @@ func TestGetVerificationStrategy(t *testing.T) { {"Mantle", 5000, StrategyEtherscan}, {"Scroll", 534352, StrategyEtherscan}, {"Sourcify chain", 295, StrategySourcify}, + {"Tempo Mainnet", 4217, StrategySourcify}, + {"Tempo Testnet Moderato", 42431, StrategySourcify}, {"L2Scan chain", 4200, StrategyL2Scan}, {"SocialScan chain", 688688, StrategySocialScan}, {"Unknown chain", 999999999, StrategyUnknown}, @@ -83,6 +85,8 @@ func TestIsChainSupportedOnSourcify(t *testing.T) { t.Parallel() require.True(t, IsChainSupportedOnSourcify(295)) + require.True(t, IsChainSupportedOnSourcify(4217)) + require.True(t, IsChainSupportedOnSourcify(42431)) require.False(t, IsChainSupportedOnSourcify(1)) }