diff --git a/.circleci/config.yml b/.circleci/config.yml index ac858b59b79be..ebc84ee53f96c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1508,6 +1508,7 @@ workflows: op-e2e/actions op-e2e/faultproofs packages/contracts-bedrock/scripts/checks + packages/contracts-bedrock/scripts/verify op-dripper requires: - contracts-bedrock-build diff --git a/packages/contracts-bedrock/scripts/verify/verify-bytecode/main.go b/packages/contracts-bedrock/scripts/verify/verify-bytecode/main.go new file mode 100644 index 0000000000000..9115bf6742e91 --- /dev/null +++ b/packages/contracts-bedrock/scripts/verify/verify-bytecode/main.go @@ -0,0 +1,513 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/fatih/color" +) + +// ImmutableReference represents an immutable reference in the contract bytecode +type ImmutableReference struct { + Offset int + Length int + Value string +} + +// BytecodeDifference represents a difference between expected and actual bytecode +type BytecodeDifference struct { + Start int + Length int + Expected string + Actual string + InImmutable bool + ImmutableName string +} + +// currentDiff is a helper struct for tracking differences during comparison +type currentDiff struct { + Start int + Expected []string + Actual []string + InImmutable bool + ImmutableName string +} + +func main() { + // Parse command line arguments + address := flag.String("address", "", "Contract address to check") + artifactPath := flag.String("artifact", "", "Path to the contract artifact JSON file") + rpcURL := flag.String("rpc", "", "RPC URL for the network") + flag.Parse() + + if *rpcURL == "" { + color.Red("Error: RPC URL is required") + flag.Usage() + os.Exit(1) + } + + color.Cyan("Comparing contract at %s with artifact %s", *address, *artifactPath) + + // Load the artifact + artifact, err := loadArtifact(*artifactPath) + if err != nil { + color.Red("Error loading artifact: %v", err) + os.Exit(1) + } + + // Get expected bytecode from artifact + expectedBytecode, err := getDeployedBytecode(artifact) + if err != nil { + color.Red("Error: %v", err) + os.Exit(1) + } + + // Get immutable references + immutableRefs, err := getImmutableReferences(artifact) + if err != nil { + color.Red("Error: %v", err) + os.Exit(1) + } + + // Get actual bytecode from the network + actualBytecode, err := getOnchainBytecode(*address, *rpcURL) + if err != nil { + color.Red("Error: %v", err) + os.Exit(1) + } + + // Find differences + differences, err := findDifferences(expectedBytecode, actualBytecode, immutableRefs) + if err != nil { + color.Red("Error: %v", err) + os.Exit(1) + } + + // Print results + printDifferences(differences, immutableRefs) + + // Exit with error code if there are non-immutable differences + for _, diff := range differences { + if !diff.InImmutable { + os.Exit(1) + } + } + + color.Green("✓ Contract bytecode matches the artifact (accounting for immutable references).") +} + +func loadArtifact(path string) (map[string]any, error) { + if path == "" { + return nil, fmt.Errorf("artifact path is required") + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read artifact file: %w", err) + } + + var artifact map[string]any + if err := json.Unmarshal(data, &artifact); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + return artifact, nil +} + +func getDeployedBytecode(artifact map[string]any) (string, error) { + // Check for Forge/Foundry artifact format + if deployedBytecode, ok := artifact["deployedBytecode"].(map[string]any); ok { + if object, ok := deployedBytecode["object"].(string); ok { + return object, nil + } + } + + // Check for standard artifact formats + if deployedBytecode, ok := artifact["deployedBytecode"].(string); ok { + return deployedBytecode, nil + } + + // Check for bytecode field + if bytecode, ok := artifact["bytecode"].(map[string]any); ok { + if object, ok := bytecode["object"].(string); ok { + return object, nil + } + } else if bytecode, ok := artifact["bytecode"].(string); ok { + return bytecode, nil + } + + return "", fmt.Errorf("could not find deployedBytecode in artifact") +} + +func getVariableNameFromAST(artifact map[string]any, varID string) string { + // Remove any prefix from the ID (sometimes IDs are prefixed with a path) + cleanID := varID + if strings.Contains(varID, ":") { + parts := strings.Split(varID, ":") + cleanID = parts[len(parts)-1] + } + + // Try to convert to int + idInt, err := strconv.Atoi(cleanID) + if err != nil { + return varID + } + + // Try to find the AST node + if ast, ok := artifact["ast"].(map[string]any); ok { + // Recursively search for the node with matching ID + name := findNodeName(ast, idInt) + if name != "" { + return name + } + } + + // Fallback to using the ID if we can't find the name + return varID +} + +func findNodeName(node any, targetID int) string { + switch n := node.(type) { + case map[string]any: + // Check if this is the node we're looking for + if id, ok := n["id"].(float64); ok && int(id) == targetID { + if name, ok := n["name"].(string); ok { + return name + } + } + + // Recursively search in all child nodes + for _, value := range n { + result := findNodeName(value, targetID) + if result != "" { + return result + } + } + case []any: + // Search in list items + for _, item := range n { + result := findNodeName(item, targetID) + if result != "" { + return result + } + } + } + return "" +} + +func getImmutableReferences(artifact map[string]any) (map[string][]ImmutableReference, error) { + references := make(map[string][]ImmutableReference) + + var immutableRefs map[string]any + + // Handle Forge/Foundry artifact format + if deployedBytecode, ok := artifact["deployedBytecode"].(map[string]any); ok { + if refs, ok := deployedBytecode["immutableReferences"].(map[string]any); ok { + immutableRefs = refs + } else { + return references, nil // No immutable references found + } + } else if refs, ok := artifact["immutableReferences"].(map[string]any); ok { + // Handle standard artifact format + immutableRefs = refs + } else { + return references, nil // No immutable references found + } + + // Process the references + for varID, refs := range immutableRefs { + // Get the variable name from AST + varName := getVariableNameFromAST(artifact, varID) + references[varName] = []ImmutableReference{} + + refsList, ok := refs.([]any) + if !ok { + continue + } + + for _, ref := range refsList { + var start, length int + + // Handle different formats of immutable references + if refMap, ok := ref.(map[string]any); ok { + if startVal, ok := refMap["start"].(float64); ok { + start = int(startVal) + } + if lengthVal, ok := refMap["length"].(float64); ok { + length = int(lengthVal) + } + } else if refArray, ok := ref.([]any); ok && len(refArray) >= 2 { + // Some formats use [start, length] array + if startVal, ok := refArray[0].(float64); ok { + start = int(startVal) + } + if lengthVal, ok := refArray[1].(float64); ok { + length = int(lengthVal) + } + } else { + color.Yellow("Warning: Unrecognized immutable reference format: %v", ref) + continue + } + + references[varName] = append(references[varName], ImmutableReference{ + Offset: start, + Length: length, + Value: "", + }) + } + } + + return references, nil +} + +func getOnchainBytecode(address string, rpcURL string) (string, error) { + if address == "" { + return "", fmt.Errorf("contract address is required") + } + + client, err := ethclient.Dial(rpcURL) + if err != nil { + return "", fmt.Errorf("failed to connect to RPC at %s: %w", rpcURL, err) + } + + code, err := client.CodeAt(context.Background(), common.HexToAddress(address), nil) + if err != nil { + return "", fmt.Errorf("failed to get code at address %s: %w", address, err) + } + + if len(code) == 0 { + return "", fmt.Errorf("no code found at address %s", address) + } + + return "0x" + hex.EncodeToString(code), nil +} + +func isInImmutableReference( + position int, + immutableRefs map[string][]ImmutableReference, +) (bool, string, *ImmutableReference) { + for varName, refs := range immutableRefs { + for i := range refs { + ref := &refs[i] + if ref.Offset <= position && position < ref.Offset+ref.Length { + return true, varName, ref + } + } + } + return false, "", nil +} + +func findDifferences( + expectedBytecode string, + actualBytecode string, + immutableRefs map[string][]ImmutableReference, +) ([]BytecodeDifference, error) { + // Remove '0x' prefix if present + expected := strings.TrimPrefix(expectedBytecode, "0x") + actual := strings.TrimPrefix(actualBytecode, "0x") + + // Convert to bytes for comparison + expectedBytes, err := hex.DecodeString(expected) + if err != nil { + return nil, fmt.Errorf("failed to decode expected bytecode: %w", err) + } + + actualBytes, err := hex.DecodeString(actual) + if err != nil { + return nil, fmt.Errorf("failed to decode actual bytecode: %w", err) + } + + // Check length differences + if len(expectedBytes) != len(actualBytes) { + color.Yellow("Warning: Bytecode length mismatch. Expected: %d, Actual: %d", + len(expectedBytes), len(actualBytes)) + } + + // Use the shorter length for comparison + compareLength := min(len(expectedBytes), len(actualBytes)) + + // Initialize all immutable reference values + for _, refs := range immutableRefs { + for i := range refs { + refs[i].Value = "" + } + } + + differences := []BytecodeDifference{} + var currDiff *currentDiff = nil + + for i := 0; i < compareLength; i++ { + inImmutable, varName, ref := isInImmutableReference(i, immutableRefs) + + // If we're in an immutable reference, collect the value + if inImmutable && ref != nil { + // Add this byte to the immutable value + ref.Value += fmt.Sprintf("%02x", actualBytes[i]) + + // If bytes differ and we're in an immutable reference, that's expected + if expectedBytes[i] != actualBytes[i] { + if currDiff == nil { + currDiff = ¤tDiff{ + Start: i, + Expected: []string{}, + Actual: []string{}, + InImmutable: true, + ImmutableName: varName, + } + } else if !currDiff.InImmutable { + // We were tracking a non-immutable diff, finish it and start a new one + differences = append(differences, BytecodeDifference{ + Start: currDiff.Start, + Length: len(currDiff.Expected), + Expected: strings.Join(currDiff.Expected, ""), + Actual: strings.Join(currDiff.Actual, ""), + InImmutable: currDiff.InImmutable, + ImmutableName: currDiff.ImmutableName, + }) + currDiff = ¤tDiff{ + Start: i, + Expected: []string{}, + Actual: []string{}, + InImmutable: true, + ImmutableName: varName, + } + } + + currDiff.Expected = append(currDiff.Expected, fmt.Sprintf("%02x", expectedBytes[i])) + currDiff.Actual = append(currDiff.Actual, fmt.Sprintf("%02x", actualBytes[i])) + } else if currDiff != nil && currDiff.InImmutable { + // End of a difference section within an immutable reference + differences = append(differences, BytecodeDifference{ + Start: currDiff.Start, + Length: len(currDiff.Expected), + Expected: strings.Join(currDiff.Expected, ""), + Actual: strings.Join(currDiff.Actual, ""), + InImmutable: currDiff.InImmutable, + ImmutableName: currDiff.ImmutableName, + }) + currDiff = nil + } + } else { + // Not in an immutable reference - any difference is an error + if expectedBytes[i] != actualBytes[i] { + if currDiff == nil { + currDiff = ¤tDiff{ + Start: i, + Expected: []string{}, + Actual: []string{}, + InImmutable: false, + ImmutableName: "", + } + } else if currDiff.InImmutable { + // We were tracking an immutable diff, finish it and start a new one + differences = append(differences, BytecodeDifference{ + Start: currDiff.Start, + Length: len(currDiff.Expected), + Expected: strings.Join(currDiff.Expected, ""), + Actual: strings.Join(currDiff.Actual, ""), + InImmutable: currDiff.InImmutable, + ImmutableName: currDiff.ImmutableName, + }) + currDiff = ¤tDiff{ + Start: i, + Expected: []string{}, + Actual: []string{}, + InImmutable: false, + ImmutableName: "", + } + } + + currDiff.Expected = append(currDiff.Expected, fmt.Sprintf("%02x", expectedBytes[i])) + currDiff.Actual = append(currDiff.Actual, fmt.Sprintf("%02x", actualBytes[i])) + } else if currDiff != nil && !currDiff.InImmutable { + // End of a difference section outside immutable reference + differences = append(differences, BytecodeDifference{ + Start: currDiff.Start, + Length: len(currDiff.Expected), + Expected: strings.Join(currDiff.Expected, ""), + Actual: strings.Join(currDiff.Actual, ""), + InImmutable: currDiff.InImmutable, + ImmutableName: currDiff.ImmutableName, + }) + currDiff = nil + } + } + } + + // Don't forget the last difference if we reached the end + if currDiff != nil { + differences = append(differences, BytecodeDifference{ + Start: currDiff.Start, + Length: len(currDiff.Expected), + Expected: strings.Join(currDiff.Expected, ""), + Actual: strings.Join(currDiff.Actual, ""), + InImmutable: currDiff.InImmutable, + ImmutableName: currDiff.ImmutableName, + }) + } + + return differences, nil +} + +func printDifferences( + differences []BytecodeDifference, + immutableRefs map[string][]ImmutableReference, +) { + // Separate immutable and non-immutable differences + var nonImmutableDiffs []BytecodeDifference + var immutableDiffs []BytecodeDifference + + for _, diff := range differences { + if diff.InImmutable { + immutableDiffs = append(immutableDiffs, diff) + } else { + nonImmutableDiffs = append(nonImmutableDiffs, diff) + } + } + + // Print summary + color.Cyan("\n=== Bytecode Comparison Summary ===") + fmt.Printf("Total differences: %d\n", len(differences)) + fmt.Printf(" - In immutable references: %d\n", len(immutableDiffs)) + fmt.Printf(" - In code: %d\n", len(nonImmutableDiffs)) + + // Print non-immutable differences (these are errors) + if len(nonImmutableDiffs) > 0 { + color.Red("\n=== Unexpected Differences in Code ===") + for _, diff := range nonImmutableDiffs { + color.Red("Position %d-%d:", diff.Start, diff.Start+diff.Length-1) + fmt.Printf(" Expected: 0x%s\n", diff.Expected) + fmt.Printf(" Actual: 0x%s\n", diff.Actual) + } + color.Red("\n⚠️ The contract bytecode does not match the artifact!") + } else { + color.Green("\n✓ No unexpected differences in code.") + } + + // Print immutable references + color.Cyan("\n=== Immutable References ===") + if len(immutableRefs) == 0 { + fmt.Println("No immutable references found in the artifact.") + } else { + for varName, refs := range immutableRefs { + color.Yellow("\n%s:", varName) + for i, ref := range refs { + if ref.Value != "" { + fmt.Printf(" [%d] Offset: %d, Length: %d\n", i, ref.Offset, ref.Length) + fmt.Printf(" Value: 0x%s\n", ref.Value) + } else { + fmt.Printf(" [%d] Offset: %d, Length: %d\n", i, ref.Offset, ref.Length) + fmt.Printf(" Value: (not modified)\n") + } + } + } + } +} diff --git a/packages/contracts-bedrock/scripts/verify/verify-bytecode/main_test.go b/packages/contracts-bedrock/scripts/verify/verify-bytecode/main_test.go new file mode 100644 index 0000000000000..bc07b31a83630 --- /dev/null +++ b/packages/contracts-bedrock/scripts/verify/verify-bytecode/main_test.go @@ -0,0 +1,646 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadArtifact(t *testing.T) { + // Create a temporary artifact file + tempDir := t.TempDir() + artifactPath := filepath.Join(tempDir, "artifact.json") + + // Test case 1: Valid artifact + validArtifact := map[string]interface{}{ + "deployedBytecode": map[string]interface{}{ + "object": "0x1234", + }, + } + artifactJSON, err := json.Marshal(validArtifact) + require.NoError(t, err) + err = os.WriteFile(artifactPath, artifactJSON, 0644) + require.NoError(t, err) + + artifact, err := loadArtifact(artifactPath) + require.NoError(t, err) + assert.Equal(t, "0x1234", artifact["deployedBytecode"].(map[string]interface{})["object"]) + + // Test case 2: Empty path + _, err = loadArtifact("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "artifact path is required") + + // Test case 3: Non-existent file + _, err = loadArtifact(filepath.Join(tempDir, "nonexistent.json")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read artifact file") + + // Test case 4: Invalid JSON + err = os.WriteFile(artifactPath, []byte("invalid json"), 0644) + require.NoError(t, err) + _, err = loadArtifact(artifactPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse JSON") +} + +func TestGetDeployedBytecode(t *testing.T) { + tests := []struct { + name string + artifact map[string]interface{} + want string + wantErr bool + }{ + { + name: "Forge/Foundry format", + artifact: map[string]interface{}{ + "deployedBytecode": map[string]interface{}{ + "object": "0x1234", + }, + }, + want: "0x1234", + wantErr: false, + }, + { + name: "Standard format with string", + artifact: map[string]interface{}{ + "deployedBytecode": "0x5678", + }, + want: "0x5678", + wantErr: false, + }, + { + name: "Bytecode object format", + artifact: map[string]interface{}{ + "bytecode": map[string]interface{}{ + "object": "0xabcd", + }, + }, + want: "0xabcd", + wantErr: false, + }, + { + name: "Bytecode string format", + artifact: map[string]interface{}{ + "bytecode": "0xef01", + }, + want: "0xef01", + wantErr: false, + }, + { + name: "No bytecode", + artifact: map[string]interface{}{}, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getDeployedBytecode(tt.artifact) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestGetVariableNameFromAST(t *testing.T) { + tests := []struct { + name string + artifact map[string]interface{} + varID string + want string + }{ + { + name: "Find variable by ID", + artifact: map[string]interface{}{ + "ast": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "id": float64(123), + "name": "testVar", + }, + }, + }, + }, + varID: "123", + want: "testVar", + }, + { + name: "Find variable with path prefix", + artifact: map[string]interface{}{ + "ast": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "id": float64(456), + "name": "prefixedVar", + }, + }, + }, + }, + varID: "path:to:456", + want: "prefixedVar", + }, + { + name: "Variable not found", + artifact: map[string]interface{}{ + "ast": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "id": float64(789), + "name": "otherVar", + }, + }, + }, + }, + varID: "999", + want: "999", // Returns the ID if not found + }, + { + name: "Non-numeric ID", + artifact: map[string]interface{}{ + "ast": map[string]interface{}{ + "nodes": []interface{}{}, + }, + }, + varID: "abc", + want: "abc", // Returns the ID if not numeric + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getVariableNameFromAST(tt.artifact, tt.varID) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFindNodeName(t *testing.T) { + tests := []struct { + name string + node interface{} + targetID int + want string + }{ + { + name: "Find node in map", + node: map[string]interface{}{ + "id": float64(123), + "name": "testNode", + }, + targetID: 123, + want: "testNode", + }, + { + name: "Find node in nested map", + node: map[string]interface{}{ + "child": map[string]interface{}{ + "id": float64(456), + "name": "nestedNode", + }, + }, + targetID: 456, + want: "nestedNode", + }, + { + name: "Find node in array", + node: map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "id": float64(789), + "name": "arrayNode", + }, + }, + }, + targetID: 789, + want: "arrayNode", + }, + { + name: "Node not found", + node: map[string]interface{}{ + "id": float64(111), + "name": "wrongNode", + }, + targetID: 999, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findNodeName(tt.node, tt.targetID) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetImmutableReferences(t *testing.T) { + tests := []struct { + name string + artifact map[string]interface{} + want map[string][]ImmutableReference + wantLen int + }{ + { + name: "Forge/Foundry format", + artifact: map[string]interface{}{ + "deployedBytecode": map[string]interface{}{ + "immutableReferences": map[string]interface{}{ + "123": []interface{}{ + map[string]interface{}{ + "start": float64(10), + "length": float64(32), + }, + }, + }, + }, + "ast": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "id": float64(123), + "name": "testVar", + }, + }, + }, + }, + want: map[string][]ImmutableReference{ + "testVar": { + { + Offset: 10, + Length: 32, + Value: "", + }, + }, + }, + wantLen: 1, + }, + { + name: "Standard format", + artifact: map[string]interface{}{ + "immutableReferences": map[string]interface{}{ + "456": []interface{}{ + []interface{}{float64(20), float64(16)}, + }, + }, + "ast": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "id": float64(456), + "name": "anotherVar", + }, + }, + }, + }, + want: map[string][]ImmutableReference{ + "anotherVar": { + { + Offset: 20, + Length: 16, + Value: "", + }, + }, + }, + wantLen: 1, + }, + { + name: "No immutable references", + artifact: map[string]interface{}{ + "deployedBytecode": map[string]interface{}{}, + }, + want: map[string][]ImmutableReference{}, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getImmutableReferences(tt.artifact) + assert.NoError(t, err) + assert.Equal(t, tt.wantLen, len(got)) + + // Check specific values for non-empty cases + if tt.wantLen > 0 { + for k, v := range tt.want { + assert.Contains(t, got, k) + assert.Equal(t, v[0].Offset, got[k][0].Offset) + assert.Equal(t, v[0].Length, got[k][0].Length) + } + } + }) + } +} + +func TestIsInImmutableReference(t *testing.T) { + immutableRefs := map[string][]ImmutableReference{ + "var1": { + {Offset: 10, Length: 5, Value: ""}, + }, + "var2": { + {Offset: 20, Length: 10, Value: ""}, + {Offset: 40, Length: 5, Value: ""}, + }, + } + + tests := []struct { + name string + position int + wantIn bool + wantVarName string + wantRef bool + }{ + { + name: "Inside first variable", + position: 12, + wantIn: true, + wantVarName: "var1", + wantRef: true, + }, + { + name: "At start of first variable", + position: 10, + wantIn: true, + wantVarName: "var1", + wantRef: true, + }, + { + name: "At end of first variable (exclusive)", + position: 15, + wantIn: false, + wantVarName: "", + wantRef: false, + }, + { + name: "Inside second variable, first reference", + position: 25, + wantIn: true, + wantVarName: "var2", + wantRef: true, + }, + { + name: "Inside second variable, second reference", + position: 42, + wantIn: true, + wantVarName: "var2", + wantRef: true, + }, + { + name: "Outside any variable", + position: 30, + wantIn: false, + wantVarName: "", + wantRef: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inImmutable, varName, ref := isInImmutableReference(tt.position, immutableRefs) + assert.Equal(t, tt.wantIn, inImmutable) + assert.Equal(t, tt.wantVarName, varName) + if tt.wantRef { + assert.NotNil(t, ref) + } else { + assert.Nil(t, ref) + } + }) + } +} + +func TestFindDifferences(t *testing.T) { + tests := []struct { + name string + expectedBytecode string + actualBytecode string + immutableRefs map[string][]ImmutableReference + wantDiffs int + wantImmutable int + wantErr bool + }{ + { + name: "No differences", + expectedBytecode: "0x1234567890abcdef", + actualBytecode: "0x1234567890abcdef", + immutableRefs: map[string][]ImmutableReference{}, + wantDiffs: 0, + wantImmutable: 0, + wantErr: false, + }, + { + name: "Difference in immutable reference", + expectedBytecode: "0x1234000000abcdef", + actualBytecode: "0x1234fffffeabcdef", + immutableRefs: map[string][]ImmutableReference{ + "testVar": { + {Offset: 2, Length: 3, Value: ""}, + }, + }, + wantDiffs: 1, + wantImmutable: 1, + wantErr: false, + }, + { + name: "Difference outside immutable reference", + expectedBytecode: "0x1234567890abcdef", + actualBytecode: "0x1234567890abcdee", // Last byte different + immutableRefs: map[string][]ImmutableReference{}, + wantDiffs: 1, + wantImmutable: 0, + wantErr: false, + }, + { + name: "Multiple differences", + expectedBytecode: "0x1234000000abcdef", + actualBytecode: "0x1234fffffeabcdee", // Immutable and non-immutable differences + immutableRefs: map[string][]ImmutableReference{ + "testVar": { + {Offset: 2, Length: 3, Value: ""}, + }, + }, + wantDiffs: 2, + wantImmutable: 1, + wantErr: false, + }, + { + name: "Invalid expected bytecode", + expectedBytecode: "0xZZZZ", + actualBytecode: "0x1234", + immutableRefs: map[string][]ImmutableReference{}, + wantDiffs: 0, + wantImmutable: 0, + wantErr: true, + }, + { + name: "Invalid actual bytecode", + expectedBytecode: "0x1234", + actualBytecode: "0xZZZZ", + immutableRefs: map[string][]ImmutableReference{}, + wantDiffs: 0, + wantImmutable: 0, + wantErr: true, + }, + { + name: "Different lengths", + expectedBytecode: "0x1234", + actualBytecode: "0x123456", + immutableRefs: map[string][]ImmutableReference{}, + wantDiffs: 0, // No differences in the common part + wantImmutable: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diffs, err := findDifferences(tt.expectedBytecode, tt.actualBytecode, tt.immutableRefs) + + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantDiffs, len(diffs)) + + // Count immutable differences + immutableCount := 0 + for _, diff := range diffs { + if diff.InImmutable { + immutableCount++ + } + } + assert.Equal(t, tt.wantImmutable, immutableCount) + }) + } +} + +func TestFindDifferencesDetailed(t *testing.T) { + // Test with specific bytecode patterns to verify exact difference detection + expected := "0x1234567890abcdef" + actual := "0x1234FF7890abFFef" + + immutableRefs := map[string][]ImmutableReference{ + "testVar": { + {Offset: 2, Length: 1, Value: ""}, // Covers the "FF" difference + }, + } + + diffs, err := findDifferences(expected, actual, immutableRefs) + require.NoError(t, err) + + // Should find 2 differences: one in immutable ref, one outside + assert.Equal(t, 2, len(diffs)) + + // First difference should be in immutable reference + assert.True(t, diffs[0].InImmutable) + assert.Equal(t, "testVar", diffs[0].ImmutableName) + assert.Equal(t, 2, diffs[0].Start) // 0-based index after 0x prefix + assert.Equal(t, 1, diffs[0].Length) + assert.Equal(t, "56", diffs[0].Expected) + assert.Equal(t, "ff", diffs[0].Actual) + + // Second difference should be outside immutable reference + assert.False(t, diffs[1].InImmutable) + assert.Equal(t, "", diffs[1].ImmutableName) + assert.Equal(t, 6, diffs[1].Start) // 0-based index after 0x prefix + assert.Equal(t, 1, diffs[1].Length) + assert.Equal(t, "cd", diffs[1].Expected) + assert.Equal(t, "ff", diffs[1].Actual) + + // Check that immutable reference value was captured + assert.Equal(t, "ff", immutableRefs["testVar"][0].Value) +} + +// TestPrintDifferences doesn't test the actual output (which goes to stdout) +// but ensures the function doesn't panic with various inputs +func TestPrintDifferences(t *testing.T) { + differences := []BytecodeDifference{ + { + Start: 10, + Length: 2, + Expected: "1234", + Actual: "5678", + InImmutable: true, + ImmutableName: "testVar", + }, + { + Start: 20, + Length: 1, + Expected: "ab", + Actual: "cd", + InImmutable: false, + ImmutableName: "", + }, + } + + immutableRefs := map[string][]ImmutableReference{ + "testVar": { + {Offset: 10, Length: 2, Value: "5678"}, + }, + } + + // This should not panic + printDifferences(differences, immutableRefs) + + // Test with empty differences + printDifferences([]BytecodeDifference{}, immutableRefs) + + // Test with empty immutable references + printDifferences(differences, map[string][]ImmutableReference{}) +} + +// Test handling of bytecode with and without 0x prefix +func TestBytecodePrefix(t *testing.T) { + expected := "0x1234" + actual := "1234" // No prefix + + diffs, err := findDifferences(expected, actual, map[string][]ImmutableReference{}) + require.NoError(t, err) + assert.Equal(t, 0, len(diffs), "Should handle different prefixes correctly") + + // Test the reverse + diffs, err = findDifferences(actual, expected, map[string][]ImmutableReference{}) + require.NoError(t, err) + assert.Equal(t, 0, len(diffs), "Should handle different prefixes correctly") +} + +// Test consecutive differences are properly grouped +func TestConsecutiveDifferences(t *testing.T) { + expected := "0x123456789a" + actual := "0x12FFFF789a" // Two consecutive bytes different + + diffs, err := findDifferences(expected, actual, map[string][]ImmutableReference{}) + require.NoError(t, err) + + // Should group consecutive differences + assert.Equal(t, 1, len(diffs), "Consecutive differences should be grouped") + assert.Equal(t, 2, diffs[0].Length, "Difference should span 2 bytes") + assert.Equal(t, "3456", diffs[0].Expected) + assert.Equal(t, "ffff", diffs[0].Actual) +} + +// Test with empty bytecode +func TestEmptyBytecode(t *testing.T) { + _, err := findDifferences("0x", "0x", map[string][]ImmutableReference{}) + assert.NoError(t, err, "Should handle empty bytecode") + + _, err = findDifferences("", "", map[string][]ImmutableReference{}) + assert.NoError(t, err, "Should handle empty bytecode without prefix") +} + +// Test with invalid hex characters +func TestInvalidHex(t *testing.T) { + _, err := findDifferences("0x123Z", "0x1234", map[string][]ImmutableReference{}) + assert.Error(t, err, "Should detect invalid hex in expected bytecode") + + _, err = findDifferences("0x1234", "0x123Z", map[string][]ImmutableReference{}) + assert.Error(t, err, "Should detect invalid hex in actual bytecode") +}