diff --git a/.circleci/config.yml b/.circleci/config.yml index 12fd931fcd8..8cdf7a5e71a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1296,6 +1296,8 @@ jobs: command: size-check - run-contracts-check: command: unused-imports-check-no-build + - run-contracts-check: + command: strict-pragma-check-no-build - run-contracts-check: command: validate-spacers-no-build - run-contracts-check: diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 434b768f8e4..363f5b6b5c6 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -293,6 +293,13 @@ unused-imports-check-no-build: # Checks for unused imports in Solidity contracts. unused-imports-check: build unused-imports-check-no-build +# Checks that contracts use strict pragma versions. Does not build contracts. +strict-pragma-check-no-build: + go run ./scripts/checks/strict-pragma + +# Checks that contracts use strict pragma versions. +strict-pragma-check: build strict-pragma-check-no-build + # Checks that the semver of contracts are valid. Does not build contracts. valid-semver-check-no-build: go run ./scripts/checks/valid-semver-check/main.go @@ -332,6 +339,7 @@ check: lint-check \ snapshots-check-no-build \ unused-imports-check-no-build \ + strict-pragma-check-no-build \ valid-semver-check-no-build \ semver-diff-check-no-build \ validate-deploy-configs \ diff --git a/packages/contracts-bedrock/scripts/checks/strict-pragma/main.go b/packages/contracts-bedrock/scripts/checks/strict-pragma/main.go new file mode 100644 index 00000000000..6b272cb5065 --- /dev/null +++ b/packages/contracts-bedrock/scripts/checks/strict-pragma/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" + + "github.com/ethereum-optimism/optimism/packages/contracts-bedrock/scripts/checks/common" +) + +// Patterns to detect contract types and pragma +var ( + // Matches "pragma solidity X.Y.Z;" (strict) vs "pragma solidity ^X.Y.Z;" or ">=X.Y.Z" (non-strict) + pragmaPattern = regexp.MustCompile(`pragma\s+solidity\s+([^;]+);`) + + // Matches "contract Name" but not "abstract contract Name" + // Uses \s* to allow indentation at start of line + contractPattern = regexp.MustCompile(`(?m)^\s*contract\s+\w+`) + + // Matches "abstract contract Name" + abstractPattern = regexp.MustCompile(`(?m)^\s*abstract\s+contract\s+\w+`) + + // Matches "library Name" + libraryPattern = regexp.MustCompile(`(?m)^\s*library\s+\w+`) + + // Matches "interface Name" + interfacePattern = regexp.MustCompile(`(?m)^\s*interface\s+\w+`) +) + +// Files that are grandfathered in (already have non-strict pragma) +// These should be fixed over time, but we don't want to block CI on them +var excludedFiles = []string{ + "src/integration/EventLogger.sol", + "src/integration/GameHelper.sol", + "src/libraries/TransientContext.sol", + "src/periphery/AssetReceiver.sol", + "src/periphery/Transactor.sol", + "src/periphery/monitoring/DisputeMonitorHelper.sol", + "src/universal/SafeSend.sol", +} + +func main() { + if _, err := common.ProcessFilesGlob( + []string{"src/**/*.sol"}, + excludedFiles, + processFile, + ); err != nil { + fmt.Printf("error: %v\n", err) + os.Exit(1) + } +} + +func processFile(filePath string) (*common.Void, []error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, []error{fmt.Errorf("failed to read file: %w", err)} + } + + contentStr := string(content) + + // Check if file contains a concrete contract (not abstract, not library, not interface) + if !hasConcreteContract(contentStr) { + return nil, nil + } + + // Check if pragma is strict + pragma := extractPragma(contentStr) + if pragma == "" { + return nil, []error{fmt.Errorf("no pragma found")} + } + + if !isStrictPragma(pragma) { + return nil, []error{fmt.Errorf("non-strict pragma '%s' - contracts must use exact version (e.g., '0.8.15' not '^0.8.15')", pragma)} + } + + return nil, nil +} + +// hasConcreteContract returns true if the file contains at least one concrete contract +// (not abstract, not library, not interface) +func hasConcreteContract(content string) bool { + // Remove comments to avoid false positives + content = removeComments(content) + + // Check for concrete contract definition + hasContract := contractPattern.MatchString(content) + if !hasContract { + return false + } + + // Make sure it's not just abstract contracts, libraries, or interfaces + // by checking if we have a "contract X" that isn't preceded by "abstract" + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Skip if it's an abstract contract, library, or interface + if abstractPattern.MatchString(trimmed) || + libraryPattern.MatchString(trimmed) || + interfacePattern.MatchString(trimmed) { + continue + } + // Check for concrete contract + if contractPattern.MatchString(trimmed) { + return true + } + } + + return false +} + +// extractPragma extracts the pragma version string from the content +func extractPragma(content string) string { + matches := pragmaPattern.FindStringSubmatch(content) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// isStrictPragma returns true if the pragma is a strict version (no ^ or >= or other operators) +func isStrictPragma(pragma string) bool { + // Strict pragma should be just a version number like "0.8.15" + // Non-strict examples: "^0.8.0", ">=0.8.0", ">=0.8.0 <0.9.0", "0.8.x" + + // Check for common non-strict indicators + nonStrictIndicators := []string{"^", ">=", "<=", ">", "<", "~", "x", "X", "*", " "} + for _, indicator := range nonStrictIndicators { + if strings.Contains(pragma, indicator) { + return false + } + } + + // Should match a simple version pattern like "0.8.15" + strictPattern := regexp.MustCompile(`^\d+\.\d+\.\d+$`) + return strictPattern.MatchString(pragma) +} + +// removeComments removes single-line and multi-line comments from Solidity code +func removeComments(content string) string { + var result strings.Builder + scanner := bufio.NewScanner(strings.NewReader(content)) + inMultiLineComment := false + + for scanner.Scan() { + line := scanner.Text() + + // Handle multi-line comments + if inMultiLineComment { + if idx := strings.Index(line, "*/"); idx != -1 { + line = line[idx+2:] + inMultiLineComment = false + } else { + continue + } + } + + // Remove multi-line comment starts + for { + startIdx := strings.Index(line, "/*") + if startIdx == -1 { + break + } + endIdx := strings.Index(line[startIdx:], "*/") + if endIdx == -1 { + line = line[:startIdx] + inMultiLineComment = true + break + } + line = line[:startIdx] + line[startIdx+endIdx+2:] + } + + // Remove single-line comments + if idx := strings.Index(line, "//"); idx != -1 { + line = line[:idx] + } + + result.WriteString(line) + result.WriteString("\n") + } + + return result.String() +} diff --git a/packages/contracts-bedrock/scripts/checks/strict-pragma/main_test.go b/packages/contracts-bedrock/scripts/checks/strict-pragma/main_test.go new file mode 100644 index 00000000000..0426dbaf2de --- /dev/null +++ b/packages/contracts-bedrock/scripts/checks/strict-pragma/main_test.go @@ -0,0 +1,288 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_hasConcreteContract(t *testing.T) { + tests := []struct { + name string + content string + expected bool + }{ + { + name: "concrete contract", + content: ` + pragma solidity 0.8.15; + contract MyContract { + } + `, + expected: true, + }, + { + name: "abstract contract only", + content: ` + pragma solidity 0.8.15; + abstract contract MyContract { + } + `, + expected: false, + }, + { + name: "library only", + content: ` + pragma solidity 0.8.15; + library MyLibrary { + } + `, + expected: false, + }, + { + name: "interface only", + content: ` + pragma solidity 0.8.15; + interface IMyInterface { + } + `, + expected: false, + }, + { + name: "abstract and concrete contract", + content: ` + pragma solidity 0.8.15; + abstract contract Base { + } + contract MyContract is Base { + } + `, + expected: true, + }, + { + name: "library and concrete contract", + content: ` + pragma solidity 0.8.15; + library MyLibrary { + } + contract MyContract { + } + `, + expected: true, + }, + { + name: "contract in comment", + content: ` + pragma solidity 0.8.15; + // contract NotReal { + // } + library MyLibrary { + } + `, + expected: false, + }, + { + name: "contract in multiline comment", + content: ` + pragma solidity 0.8.15; + /* + contract NotReal { + } + */ + library MyLibrary { + } + `, + expected: false, + }, + { + name: "empty content", + content: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasConcreteContract(tt.content) + require.Equal(t, tt.expected, result) + }) + } +} + +func Test_extractPragma(t *testing.T) { + tests := []struct { + name string + content string + expected string + }{ + { + name: "strict pragma", + content: ` + pragma solidity 0.8.15; + contract MyContract {} + `, + expected: "0.8.15", + }, + { + name: "caret pragma", + content: ` + pragma solidity ^0.8.0; + contract MyContract {} + `, + expected: "^0.8.0", + }, + { + name: "range pragma", + content: ` + pragma solidity >=0.8.0 <0.9.0; + contract MyContract {} + `, + expected: ">=0.8.0 <0.9.0", + }, + { + name: "greater than pragma", + content: ` + pragma solidity >=0.8.0; + contract MyContract {} + `, + expected: ">=0.8.0", + }, + { + name: "no pragma", + content: "contract MyContract {}", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractPragma(tt.content) + require.Equal(t, tt.expected, result) + }) + } +} + +func Test_isStrictPragma(t *testing.T) { + tests := []struct { + name string + pragma string + expected bool + }{ + { + name: "strict version", + pragma: "0.8.15", + expected: true, + }, + { + name: "strict version with different numbers", + pragma: "0.8.28", + expected: true, + }, + { + name: "caret version", + pragma: "^0.8.0", + expected: false, + }, + { + name: "greater than or equal", + pragma: ">=0.8.0", + expected: false, + }, + { + name: "less than or equal", + pragma: "<=0.9.0", + expected: false, + }, + { + name: "range", + pragma: ">=0.8.0 <0.9.0", + expected: false, + }, + { + name: "tilde version", + pragma: "~0.8.0", + expected: false, + }, + { + name: "wildcard x", + pragma: "0.8.x", + expected: false, + }, + { + name: "wildcard X", + pragma: "0.8.X", + expected: false, + }, + { + name: "wildcard star", + pragma: "0.8.*", + expected: false, + }, + { + name: "empty pragma", + pragma: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isStrictPragma(tt.pragma) + require.Equal(t, tt.expected, result) + }) + } +} + +func Test_removeComments(t *testing.T) { + tests := []struct { + name string + content string + shouldNotContain string + shouldContain string + }{ + { + name: "single line comment", + content: `contract MyContract { + // this is a comment + uint256 value; + }`, + shouldNotContain: "this is a comment", + shouldContain: "uint256 value", + }, + { + name: "multi line comment", + content: `contract MyContract { + /* this is + a multi-line + comment */ + uint256 value; + }`, + shouldNotContain: "multi-line", + shouldContain: "uint256 value", + }, + { + name: "inline comment", + content: `contract MyContract { + uint256 value; // inline comment + }`, + shouldNotContain: "inline comment", + shouldContain: "uint256 value", + }, + { + name: "no comments", + content: "contract MyContract {}", + shouldNotContain: "", + shouldContain: "contract MyContract", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := removeComments(tt.content) + if tt.shouldNotContain != "" { + require.NotContains(t, result, tt.shouldNotContain) + } + require.Contains(t, result, tt.shouldContain) + }) + } +}