diff --git a/engine/cld/verification/evm/sourcify_verifier.go b/engine/cld/verification/evm/sourcify_verifier.go index 18c499849..3ab827234 100644 --- a/engine/cld/verification/evm/sourcify_verifier.go +++ b/engine/cld/verification/evm/sourcify_verifier.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strconv" "strings" "time" @@ -19,12 +18,46 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" ) +// sourcifyAPIResponse is the legacy v1 response used by IsVerified (GET /files/any/). type sourcifyAPIResponse struct { Status string `json:"status"` } -type sourcifyVerificationResponse struct { - Result []sourcifyAPIResponse `json:"result"` +// sourcifyV1VerifyResponse is the response from POST /verify/solc-json (legacy v1). +type sourcifyV1VerifyResponse struct { + Result []sourcifyV1Result `json:"result"` +} + +type sourcifyV1Result struct { + Address string `json:"address"` + ChainID string `json:"chainId"` + Status string `json:"status"` +} + +// sourcifyV2SubmitResponse is the response from POST /v2/verify/{chainId}/{address}. +type sourcifyV2SubmitResponse struct { + VerificationID string `json:"verificationId"` +} + +// sourcifyV2JobResponse is the response from GET /v2/verify/{verificationId}. +type sourcifyV2JobResponse struct { + IsJobCompleted bool `json:"isJobCompleted"` + VerificationID string `json:"verificationId"` + Contract *sourcifyV2Match `json:"contract,omitempty"` + Error *sourcifyV2Error `json:"error,omitempty"` +} + +type sourcifyV2Match struct { + Match *string `json:"match"` + CreationMatch *string `json:"creationMatch"` + RuntimeMatch *string `json:"runtimeMatch"` + ChainID string `json:"chainId"` + Address string `json:"address"` +} + +type sourcifyV2Error struct { + CustomCode string `json:"customCode"` + Message string `json:"message"` } func newSourcifyVerifier(cfg VerifierConfig) (verification.Verifiable, error) { @@ -33,9 +66,15 @@ func newSourcifyVerifier(cfg VerifierConfig) (verification.Verifiable, error) { return nil, fmt.Errorf("sourcify API URL not configured for chain %s", cfg.Chain.Name) } + var rpcURL string + if len(cfg.Network.RPCs) > 0 { + rpcURL = cfg.Network.RPCs[0].HTTPURL + } + return &sourcifyVerifier{ chain: cfg.Chain, apiURL: strings.TrimSuffix(apiURL, "/"), + rpcURL: rpcURL, address: cfg.Address, metadata: cfg.Metadata, contractType: cfg.ContractType, @@ -49,6 +88,7 @@ func newSourcifyVerifier(cfg VerifierConfig) (verification.Verifiable, error) { type sourcifyVerifier struct { chain chainsel.Chain apiURL string + rpcURL string address string metadata SolidityContractMetadata contractType string @@ -63,9 +103,8 @@ func (v *sourcifyVerifier) String() string { } func (v *sourcifyVerifier) IsVerified(ctx context.Context) (bool, error) { - resp, err := sendSourcifyRequest[sourcifyAPIResponse](ctx, v.httpClient, v.chain.EvmChainID, "GET", "files/any", v.apiURL, map[string]string{ - "address": v.address, - }) + checkURL := fmt.Sprintf("%s/files/any/%d/%s", v.apiURL, v.chain.EvmChainID, v.address) + resp, err := doSourcifyRequest[sourcifyAPIResponse](ctx, v.httpClient, http.MethodGet, checkURL, nil) if err != nil { if strings.Contains(err.Error(), "Files have not been found") { return false, nil @@ -88,6 +127,60 @@ func (v *sourcifyVerifier) Verify(ctx context.Context) error { return nil } + err = v.verifyV2(ctx) + if err != nil && isV2EndpointNotFound(err) { + v.lggr.Infof("Sourcify v2 API not available, falling back to v1 for %s", v) + return v.verifyV1(ctx) + } + + return err +} + +// verifyV2 uses the current Sourcify v2 API (async ticketing). +// POST /v2/verify/{chainId}/{address} -> poll GET /v2/verify/{verificationId}. +func (v *sourcifyVerifier) verifyV2(ctx context.Context) error { + contractIdentifier := v.metadata.Name + if !strings.Contains(contractIdentifier, ":") { + for sourcePath := range v.metadata.Sources { + contractIdentifier = sourcePath + ":" + v.metadata.Name + break + } + } + + requestData := map[string]any{ + "stdJsonInput": map[string]any{ + "language": v.metadata.Language, + "sources": v.metadata.Sources, + "settings": v.metadata.Settings, + }, + "compilerVersion": v.metadata.Version, + "contractIdentifier": contractIdentifier, + } + + if txHash, err := v.findCreationTxHash(ctx); err != nil { + v.lggr.Infof("Could not look up creation tx hash for %s: %s", v, err) + } else { + requestData["creationTransactionHash"] = txHash + } + + submitURL := fmt.Sprintf("%s/v2/verify/%d/%s", v.apiURL, v.chain.EvmChainID, v.address) + submitResp, err := doSourcifyRequest[sourcifyV2SubmitResponse](ctx, v.httpClient, http.MethodPost, submitURL, requestData) + if err != nil { + return fmt.Errorf("failed to submit verification: %w", err) + } + + if submitResp.VerificationID == "" { + return errors.New("no verification ID returned from sourcify") + } + + v.lggr.Infof("Verification submitted for %s (id: %s), polling for result...", v, submitResp.VerificationID) + + return v.pollVerificationJob(ctx, submitResp.VerificationID) +} + +// verifyV1 uses the legacy Sourcify v1 API (synchronous, single-request). +// POST /verify/solc-json. Used as fallback for self-hosted instances that lack v2. +func (v *sourcifyVerifier) verifyV1(ctx context.Context) error { sourceCode, err := v.metadata.SourceCode() if err != nil { return fmt.Errorf("failed to get source code: %w", err) @@ -101,57 +194,269 @@ func (v *sourcifyVerifier) Verify(ctx context.Context) error { requestData := map[string]any{ "address": v.address, "chain": strconv.FormatUint(v.chain.EvmChainID, 10), - "files": map[string]string{"value": sourceCode}, + "files": map[string]string{"SolcJsonInput.json": sourceCode}, "compilerVersion": v.metadata.Version, "contractName": contractName, } - resp, err := sendSourcifyRequest[sourcifyVerificationResponse](ctx, v.httpClient, v.chain.EvmChainID, "POST", "/verify/solc-json", v.apiURL, requestData) + if txHash, err := v.findCreationTxHash(ctx); err != nil { + v.lggr.Infof("Could not look up creation tx hash for %s: %s", v, err) + } else { + requestData["creatorTxHash"] = txHash + } + + submitURL := fmt.Sprintf("%s/verify/solc-json", v.apiURL) + resp, err := doSourcifyRequest[sourcifyV1VerifyResponse](ctx, v.httpClient, http.MethodPost, submitURL, requestData) if err != nil { return fmt.Errorf("failed to verify contract: %w", err) } if len(resp.Result) == 0 { return errors.New("invalid verification response") } - if resp.Result[0].Status != "partial" && resp.Result[0].Status != "full" { - return fmt.Errorf("unexpected verification status: %s", resp.Result[0].Status) + + status := resp.Result[0].Status + if status != "perfect" && status != "partial" { + return fmt.Errorf("unexpected verification status: %s", status) } - v.lggr.Infof("Verification status - %s", resp.Result[0].Status) + v.lggr.Infof("Verification status - %s", status) return nil } -func sendSourcifyRequest[T any](ctx context.Context, client *http.Client, chainID uint64, method, path, apiURL string, extraParams any) (T, error) { - var empty T - if apiURL == "" { - return empty, errors.New("sourcify API URL cannot be empty") +// isV2EndpointNotFound detects when a Sourcify instance doesn't support the v2 API. +// Self-hosted instances (e.g. Ronin) may run older versions without v2 routes. +func isV2EndpointNotFound(err error) bool { + return strings.Contains(err.Error(), "Cannot POST") +} + +func (v *sourcifyVerifier) pollVerificationJob(ctx context.Context, verificationID string) error { + pollDur := v.pollInterval + if pollDur <= 0 { + pollDur = 5 * time.Second + } + + pollURL := fmt.Sprintf("%s/v2/verify/%s", v.apiURL, verificationID) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollDur): + } + + jobResp, err := doSourcifyRequest[sourcifyV2JobResponse](ctx, v.httpClient, http.MethodGet, pollURL, nil) + if err != nil { + return fmt.Errorf("failed to poll verification status: %w", err) + } + + if !jobResp.IsJobCompleted { + v.lggr.Infof("Verification in progress for %s...", v) + continue + } + + if jobResp.Error != nil { + return fmt.Errorf("verification failed: [%s] %s", jobResp.Error.CustomCode, jobResp.Error.Message) + } + + if jobResp.Contract != nil && jobResp.Contract.Match != nil { + match := *jobResp.Contract.Match + if match == "match" || match == "exact_match" { + v.lggr.Infof("Verification succeeded for %s - %s", v, match) + return nil + } + } + + return errors.New("verification completed but contract was not matched") + } +} + +// findCreationTxHash looks up the transaction that deployed the contract by binary-searching +// block heights via eth_getCode, then scanning the creation block's receipts. +func (v *sourcifyVerifier) findCreationTxHash(ctx context.Context) (string, error) { + if v.rpcURL == "" { + return "", errors.New("no RPC URL configured") + } + + latestBlock, err := v.ethBlockNumber(ctx) + if err != nil { + return "", fmt.Errorf("failed to get latest block: %w", err) + } + + creationBlock, err := v.binarySearchCreationBlock(ctx, 0, latestBlock) + if err != nil { + return "", fmt.Errorf("failed to find creation block: %w", err) + } + + v.lggr.Infof("Found creation block %d for %s, scanning transactions...", creationBlock, v.address) + + return v.findCreationTxInBlock(ctx, creationBlock) +} + +// binarySearchCreationBlock finds the block number where the contract first appeared. +func (v *sourcifyVerifier) binarySearchCreationBlock(ctx context.Context, low, high uint64) (uint64, error) { + for low < high { + mid := low + (high-low)/2 + code, err := v.ethGetCode(ctx, mid) + if err != nil { + return 0, err + } + if code == "0x" || code == "" { + low = mid + 1 + } else { + high = mid + } } + + return low, nil +} + +// findCreationTxInBlock gets all transactions in a block and checks receipts to find +// which transaction created the contract at v.address. +func (v *sourcifyVerifier) findCreationTxInBlock(ctx context.Context, blockNum uint64) (string, error) { + blockHex := fmt.Sprintf("0x%x", blockNum) + + type jsonrpcResponse struct { + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + + blockResp, err := v.rpcCall(ctx, "eth_getBlockByNumber", []any{blockHex, false}) + if err != nil { + return "", fmt.Errorf("eth_getBlockByNumber failed: %w", err) + } + + var block struct { + Transactions []string `json:"transactions"` + } + if err := json.Unmarshal(blockResp, &block); err != nil { + return "", fmt.Errorf("failed to parse block: %w", err) + } + + target := strings.ToLower(v.address) + for _, txHash := range block.Transactions { + receiptResp, err := v.rpcCall(ctx, "eth_getTransactionReceipt", []any{txHash}) + if err != nil { + continue + } + + var receipt struct { + ContractAddress *string `json:"contractAddress"` + } + if err := json.Unmarshal(receiptResp, &receipt); err != nil { + continue + } + if receipt.ContractAddress != nil && strings.EqualFold(*receipt.ContractAddress, target) { + return txHash, nil + } + } + + return "", fmt.Errorf("no transaction in block %d created contract %s", blockNum, v.address) +} + +func (v *sourcifyVerifier) ethBlockNumber(ctx context.Context) (uint64, error) { + resp, err := v.rpcCall(ctx, "eth_blockNumber", []any{}) + if err != nil { + return 0, err + } + + var hexNum string + if err := json.Unmarshal(resp, &hexNum); err != nil { + return 0, fmt.Errorf("failed to parse block number: %w", err) + } + + return strconv.ParseUint(strings.TrimPrefix(hexNum, "0x"), 16, 64) +} + +func (v *sourcifyVerifier) ethGetCode(ctx context.Context, blockNum uint64) (string, error) { + blockHex := fmt.Sprintf("0x%x", blockNum) + resp, err := v.rpcCall(ctx, "eth_getCode", []any{v.address, blockHex}) + if err != nil { + return "", err + } + + var code string + if err := json.Unmarshal(resp, &code); err != nil { + return "", fmt.Errorf("failed to parse code response: %w", err) + } + + return code, nil +} + +// rpcCall makes a JSON-RPC 2.0 call to the chain's RPC endpoint. +func (v *sourcifyVerifier) rpcCall(ctx context.Context, method string, params any) (json.RawMessage, error) { + reqBody := map[string]any{ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal RPC request: %w", err) + } + + client := v.httpClient + if client == nil { + client = http.DefaultClient + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, v.rpcURL, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create RPC request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("RPC request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read RPC response: %w", err) + } + + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + return nil, fmt.Errorf("failed to parse RPC response: %w", err) + } + if rpcResp.Error != nil { + return nil, fmt.Errorf("RPC error: %s", rpcResp.Error.Message) + } + + return rpcResp.Result, nil +} + +// doSourcifyRequest sends an HTTP request and decodes the JSON response. +// It accepts any 2xx status code as success. +func doSourcifyRequest[T any](ctx context.Context, client *http.Client, method, reqURL string, body any) (T, error) { + var empty T if client == nil { client = http.DefaultClient } var httpReq *http.Request var err error - if method == "GET" { - baseURL, parseErr := url.Parse(apiURL) - if parseErr != nil { - return empty, fmt.Errorf("failed to parse base URL: %w", parseErr) - } - params := extraParams.(map[string]string) - fullURL := baseURL.JoinPath(path, strconv.FormatUint(chainID, 10)) - for _, value := range params { - fullURL = fullURL.JoinPath(value) - } - httpReq, err = http.NewRequestWithContext(ctx, method, fullURL.String(), nil) - } else { - jsonData, marshalErr := json.Marshal(extraParams) + if body != nil { + jsonData, marshalErr := json.Marshal(body) if marshalErr != nil { return empty, fmt.Errorf("failed to marshal JSON: %w", marshalErr) } - httpReq, err = http.NewRequestWithContext(ctx, method, apiURL+path, bytes.NewReader(jsonData)) + httpReq, err = http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonData)) if err == nil { httpReq.Header.Set("Content-Type", "application/json") } + } else { + httpReq, err = http.NewRequestWithContext(ctx, method, reqURL, nil) } if err != nil { return empty, fmt.Errorf("failed to create request: %w", err) @@ -163,16 +468,16 @@ func sendSourcifyRequest[T any](ctx context.Context, client *http.Client, chainI } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) if err != nil { return empty, fmt.Errorf("failed to read response body: %w", err) } - if resp.StatusCode != http.StatusOK { - return empty, fmt.Errorf("http error - status=%d body=%s", resp.StatusCode, string(body)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return empty, fmt.Errorf("http error - status=%d body=%s", resp.StatusCode, string(respBody)) } var apiResp T - if err := json.Unmarshal(body, &apiResp); err != nil { + if err := json.Unmarshal(respBody, &apiResp); err != nil { return empty, fmt.Errorf("failed to decode response: %w", err) } diff --git a/engine/cld/verification/evm/sourcify_verifier_test.go b/engine/cld/verification/evm/sourcify_verifier_test.go index 44bc065f3..40a67b937 100644 --- a/engine/cld/verification/evm/sourcify_verifier_test.go +++ b/engine/cld/verification/evm/sourcify_verifier_test.go @@ -6,9 +6,13 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" + "sync/atomic" "testing" + "time" 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" @@ -78,3 +82,266 @@ func TestSourcifyVerifier_IsVerified_NotVerified(t *testing.T) { require.NoError(t, err) require.False(t, verified) } + +func TestSourcifyVerifier_Verify_Success(t *testing.T) { + t.Parallel() + + var pollCount atomic.Int32 + matchStr := "exact_match" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/any/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Files have not been found")) + + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v2/verify/"): + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + assert.NotNil(t, body["stdJsonInput"]) + assert.Equal(t, "0.8.19+commit.abc", body["compilerVersion"]) + assert.Equal(t, "contracts/Test.sol:Test", body["contractIdentifier"]) + + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(sourcifyV2SubmitResponse{ + VerificationID: "test-verification-id", + }) + + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/verify/"): + count := pollCount.Add(1) + if count < 2 { + _ = json.NewEncoder(w).Encode(sourcifyV2JobResponse{ + IsJobCompleted: false, + VerificationID: "test-verification-id", + }) + } else { + _ = json.NewEncoder(w).Encode(sourcifyV2JobResponse{ + IsJobCompleted: true, + VerificationID: "test-verification-id", + Contract: &sourcifyV2Match{ + Match: &matchStr, + ChainID: "295", + Address: "0x123", + }, + }) + } + } + })) + defer server.Close() + + targetURL, _ := url.Parse(server.URL) + client := &http.Client{Transport: &redirectTransport{target: targetURL}} + + chain, ok := chainsel.ChainBySelector(chainsel.HEDERA_MAINNET.Selector) + require.True(t, ok) + + v, err := NewVerifier(StrategySourcify, VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: "https://sourcify.dev/server"}}, + Address: "0x123", + Metadata: SolidityContractMetadata{ + Version: "0.8.19+commit.abc", + Language: "Solidity", + Settings: map[string]any{"optimizer": map[string]any{"enabled": true}}, + Sources: map[string]any{"contracts/Test.sol": map[string]any{"content": "contract Test {}"}}, + Name: "contracts/Test.sol:Test", + }, + ContractType: "Test", + Version: "1.0.0", + PollInterval: 10 * time.Millisecond, + Logger: logger.Nop(), + HTTPClient: client, + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.NoError(t, err) + require.GreaterOrEqual(t, pollCount.Load(), int32(2)) +} + +func TestSourcifyVerifier_Verify_Error(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/any/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Files have not been found")) + + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v2/verify/"): + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(sourcifyV2SubmitResponse{ + VerificationID: "test-verification-id", + }) + + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/verify/"): + _ = json.NewEncoder(w).Encode(sourcifyV2JobResponse{ + IsJobCompleted: true, + VerificationID: "test-verification-id", + Error: &sourcifyV2Error{ + CustomCode: "no_match", + Message: "The onchain and recompiled bytecodes don't match.", + }, + }) + } + })) + defer server.Close() + + targetURL, _ := url.Parse(server.URL) + client := &http.Client{Transport: &redirectTransport{target: targetURL}} + + chain, ok := chainsel.ChainBySelector(chainsel.HEDERA_MAINNET.Selector) + require.True(t, ok) + + v, err := NewVerifier(StrategySourcify, VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: "https://sourcify.dev/server"}}, + Address: "0x123", + Metadata: SolidityContractMetadata{ + Version: "0.8.19+commit.abc", + Language: "Solidity", + Sources: map[string]any{"contracts/Test.sol": map[string]any{"content": "contract Test {}"}}, + Name: "contracts/Test.sol:Test", + }, + ContractType: "Test", + Version: "1.0.0", + PollInterval: 10 * time.Millisecond, + Logger: logger.Nop(), + HTTPClient: client, + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "no_match") +} + +func TestSourcifyVerifier_Verify_ContractIdentifierWithoutPath(t *testing.T) { + t.Parallel() + + var capturedIdentifier string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/any/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Files have not been found")) + + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v2/verify/"): + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + capturedIdentifier, _ = body["contractIdentifier"].(string) + + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(sourcifyV2SubmitResponse{ + VerificationID: "test-id", + }) + + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/verify/"): + matchStr := "exact_match" + _ = json.NewEncoder(w).Encode(sourcifyV2JobResponse{ + IsJobCompleted: true, + VerificationID: "test-id", + Contract: &sourcifyV2Match{Match: &matchStr}, + }) + } + })) + defer server.Close() + + targetURL, _ := url.Parse(server.URL) + client := &http.Client{Transport: &redirectTransport{target: targetURL}} + + chain, ok := chainsel.ChainBySelector(chainsel.HEDERA_MAINNET.Selector) + require.True(t, ok) + + v, err := NewVerifier(StrategySourcify, VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: "https://sourcify.dev/server"}}, + Address: "0x123", + Metadata: SolidityContractMetadata{ + Version: "0.8.19+commit.abc", + Language: "Solidity", + Sources: map[string]any{"contracts/MyContract.sol": map[string]any{"content": "contract MyContract {}"}}, + Name: "MyContract", + }, + ContractType: "MyContract", + Version: "1.0.0", + PollInterval: 10 * time.Millisecond, + Logger: logger.Nop(), + HTTPClient: client, + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.NoError(t, err) + require.Contains(t, capturedIdentifier, "MyContract") + require.Contains(t, capturedIdentifier, ":") +} + +func TestSourcifyVerifier_Verify_V1Fallback(t *testing.T) { + t.Parallel() + + var capturedV1Body map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/files/any/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("Files have not been found")) + + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v2/verify/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`
Cannot POST /v2/verify/295/0x123
`)) + + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/verify/solc-json"): + _ = json.NewDecoder(r.Body).Decode(&capturedV1Body) + _ = json.NewEncoder(w).Encode(sourcifyV1VerifyResponse{ + Result: []sourcifyV1Result{{Status: "perfect", Address: "0x123", ChainID: "295"}}, + }) + } + })) + defer server.Close() + + targetURL, _ := url.Parse(server.URL) + client := &http.Client{Transport: &redirectTransport{target: targetURL}} + + chain, ok := chainsel.ChainBySelector(chainsel.HEDERA_MAINNET.Selector) + require.True(t, ok) + + v, err := NewVerifier(StrategySourcify, VerifierConfig{ + Chain: chain, + Network: cfgnet.Network{ChainSelector: chain.Selector, BlockExplorer: cfgnet.BlockExplorer{URL: "https://sourcify.dev/server"}}, + Address: "0x123", + Metadata: SolidityContractMetadata{ + Version: "0.8.19+commit.abc", + Language: "Solidity", + Settings: map[string]any{"optimizer": map[string]any{"enabled": true}}, + Sources: map[string]any{"contracts/Test.sol": map[string]any{"content": "contract Test {}"}}, + Name: "contracts/Test.sol:Test", + }, + ContractType: "Test", + Version: "1.0.0", + PollInterval: 10 * time.Millisecond, + Logger: logger.Nop(), + HTTPClient: client, + }) + require.NoError(t, err) + + err = v.Verify(context.Background()) + require.NoError(t, err) + + require.NotNil(t, capturedV1Body) + assert.Equal(t, "0x123", capturedV1Body["address"]) + assert.Equal(t, "Test", capturedV1Body["contractName"]) + files, ok := capturedV1Body["files"].(map[string]any) + require.True(t, ok) + assert.Contains(t, files, "SolcJsonInput.json") +}