diff --git a/cmd/generate-bindings/bindings/bindgen.go b/cmd/generate-bindings/bindings/bindgen.go index 94830b14..7b7478b4 100644 --- a/cmd/generate-bindings/bindings/bindgen.go +++ b/cmd/generate-bindings/bindings/bindgen.go @@ -26,6 +26,32 @@ var tsTpl string //go:embed mockcontract.ts.tpl var tsMockTpl string +// readABI reads an ABI file and returns the raw ABI JSON array. +// For .json files (Solidity compiler artifacts), the ABI is extracted from the +// top-level "abi" field. For all other extensions (.abi etc.), the file content +// is returned as-is. +func readABI(path string) ([]byte, error) { + data, err := os.ReadFile(path) //nolint:gosec // G703 -- path from trusted CLI flags + if err != nil { + return nil, fmt.Errorf("read ABI %q: %w", path, err) + } + + if strings.HasSuffix(path, ".json") { + var artifact struct { + ABI json.RawMessage `json:"abi"` + } + if err := json.Unmarshal(data, &artifact); err != nil { + return nil, fmt.Errorf("failed to parse JSON artifact %q: %w", path, err) + } + if artifact.ABI == nil { + return nil, fmt.Errorf("JSON file %q does not contain an \"abi\" field", path) + } + return artifact.ABI, nil + } + + return data, nil +} + func GenerateBindings( combinedJSONPath string, // path to combined-json, or "" abiPath string, // path to a single ABI JSON, or "" @@ -70,11 +96,11 @@ func GenerateBindings( case abiPath != "": // Single-ABI mode - abiBytes, err := os.ReadFile(abiPath) //nolint:gosec // G703 -- path from trusted CLI flags + abiBytes, err := readABI(abiPath) if err != nil { - return fmt.Errorf("read ABI %q: %w", abiPath, err) + return err } - // validate JSON + // validate that the extracted content is valid JSON if err := json.Unmarshal(abiBytes, new(interface{})); err != nil { return fmt.Errorf("invalid ABI JSON %q: %w", abiPath, err) } @@ -125,9 +151,9 @@ func GenerateBindingsTS( return errors.New("must provide abiPath") } - abiBytes, err := os.ReadFile(abiPath) //nolint:gosec // G703 -- path from trusted CLI flags + abiBytes, err := readABI(abiPath) if err != nil { - return fmt.Errorf("read ABI %q: %w", abiPath, err) + return err } if err := json.Unmarshal(abiBytes, new(interface{})); err != nil { return fmt.Errorf("invalid ABI JSON %q: %w", abiPath, err) diff --git a/cmd/generate-bindings/generate-bindings.go b/cmd/generate-bindings/generate-bindings.go index 8b10c170..63691e80 100644 --- a/cmd/generate-bindings/generate-bindings.go +++ b/cmd/generate-bindings/generate-bindings.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "github.com/rs/zerolog" @@ -38,7 +39,10 @@ Supports EVM chain family with Go and TypeScript languages. The target language is auto-detected from project files, or can be specified explicitly with --language. Each contract gets its own package subdirectory to avoid naming conflicts. -For example, IERC20.abi generates bindings in generated/ierc20/ package.`, +For example, IERC20.abi generates bindings in generated/ierc20/ package. + +Both raw ABI files (*.abi) and JSON artifact files (*.json) are supported. +For JSON files the ABI is read from the top-level "abi" field.`, Example: " cre generate-bindings evm", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -58,7 +62,7 @@ For example, IERC20.abi generates bindings in generated/ierc20/ package.`, generateBindingsCmd.Flags().StringP("project-root", "p", "", "Path to project root directory (defaults to current directory)") generateBindingsCmd.Flags().StringP("language", "l", "", "Target language: go, typescript (auto-detected from project files when omitted)") - generateBindingsCmd.Flags().StringP("abi", "a", "", "Path to ABI directory (defaults to contracts/{chain-family}/src/abi/)") + generateBindingsCmd.Flags().StringP("abi", "a", "", "Path to ABI directory (defaults to contracts/{chain-family}/src/abi/). Supports *.abi and *.json files") generateBindingsCmd.Flags().StringP("pkg", "k", "bindings", "Base package name (each contract gets its own subdirectory)") return generateBindingsCmd @@ -168,6 +172,32 @@ func (h *handler) ResolveInputs(args []string, v *viper.Viper) (Inputs, error) { }, nil } +// findAbiFiles returns all supported ABI files (*.abi and *.json) found in dir. +func findAbiFiles(dir string) ([]string, error) { + abiFiles, err := filepath.Glob(filepath.Join(dir, "*.abi")) + if err != nil { + return nil, err + } + jsonFiles, err := filepath.Glob(filepath.Join(dir, "*.json")) + if err != nil { + return nil, err + } + all := append(abiFiles, jsonFiles...) + sort.Strings(all) + return all, nil +} + +// contractNameFromFile returns the contract name by stripping the .abi or .json +// extension from the base filename. +func contractNameFromFile(path string) string { + name := filepath.Base(path) + ext := filepath.Ext(name) + if ext != "" { + name = name[:len(name)-len(ext)] + } + return name +} + func (h *handler) ValidateInputs(inputs Inputs) error { validate, err := validation.NewValidator() if err != nil { @@ -186,15 +216,14 @@ func (h *handler) ValidateInputs(inputs Inputs) error { return fmt.Errorf("failed to access ABI path: %w", err) } - // Validate that if AbiPath is a directory, it contains ABI files (*.abi for both languages) + // Validate that if AbiPath is a directory, it contains ABI files (*.abi or *.json) if info, err := os.Stat(inputs.AbiPath); err == nil && info.IsDir() { - abiExt := "*.abi" - files, err := filepath.Glob(filepath.Join(inputs.AbiPath, abiExt)) + files, err := findAbiFiles(inputs.AbiPath) if err != nil { return fmt.Errorf("failed to check for ABI files in directory: %w", err) } if len(files) == 0 { - return fmt.Errorf("no %s files found in directory: %s", abiExt, inputs.AbiPath) + return fmt.Errorf("no *.abi or *.json files found in directory: %s", inputs.AbiPath) } } @@ -251,21 +280,29 @@ func contractNameToPackage(contractName string) string { } func (h *handler) processAbiDirectory(inputs Inputs) error { - abiExt := "*.abi" - files, err := filepath.Glob(filepath.Join(inputs.AbiPath, abiExt)) + files, err := findAbiFiles(inputs.AbiPath) if err != nil { return fmt.Errorf("failed to find ABI files: %w", err) } if len(files) == 0 { - return fmt.Errorf("no %s files found in directory: %s", abiExt, inputs.AbiPath) + return fmt.Errorf("no *.abi or *.json files found in directory: %s", inputs.AbiPath) + } + + // Detect duplicate contract names across extensions (e.g. Foo.abi and Foo.json) + contractNames := make(map[string]string) // contract name -> originating file + for _, f := range files { + name := contractNameFromFile(f) + if prev, exists := contractNames[name]; exists { + return fmt.Errorf("duplicate contract name %q: found in both %s and %s", name, filepath.Base(prev), filepath.Base(f)) + } + contractNames[name] = f } if inputs.GoLang { packageNames := make(map[string]bool) for _, abiFile := range files { - contractName := filepath.Base(abiFile) - contractName = contractName[:len(contractName)-4] + contractName := contractNameFromFile(abiFile) packageName := contractNameToPackage(contractName) if _, exists := packageNames[packageName]; exists { return fmt.Errorf("package name collision: multiple contracts would generate the same package name '%s' (contracts are converted to snake_case for package names). Please rename one of your contract files to avoid this conflict", packageName) @@ -279,9 +316,7 @@ func (h *handler) processAbiDirectory(inputs Inputs) error { // Process each ABI file for _, abiFile := range files { - contractName := filepath.Base(abiFile) - ext := filepath.Ext(contractName) - contractName = contractName[:len(contractName)-len(ext)] + contractName := contractNameFromFile(abiFile) if inputs.TypeScript { outputFile := filepath.Join(inputs.TSOutPath, contractName+".ts") @@ -340,11 +375,7 @@ func (h *handler) processAbiDirectory(inputs Inputs) error { } func (h *handler) processSingleAbi(inputs Inputs) error { - contractName := filepath.Base(inputs.AbiPath) - ext := filepath.Ext(contractName) - if ext != "" { - contractName = contractName[:len(contractName)-len(ext)] - } + contractName := contractNameFromFile(inputs.AbiPath) if inputs.TypeScript { outputFile := filepath.Join(inputs.TSOutPath, contractName+".ts") diff --git a/cmd/generate-bindings/generate-bindings_test.go b/cmd/generate-bindings/generate-bindings_test.go index f7aac699..6754f7c7 100644 --- a/cmd/generate-bindings/generate-bindings_test.go +++ b/cmd/generate-bindings/generate-bindings_test.go @@ -368,6 +368,10 @@ func TestEndToEnd_TypeScriptGeneration(t *testing.T) { err = os.WriteFile(filepath.Join(abiDir, "SimpleContract.abi"), []byte(abiContent), 0600) require.NoError(t, err) + jsonContent := `{"abi":[{"type":"function","name":"getBalance","inputs":[],"outputs":[{"name":"","type":"uint256"}],"stateMutability":"view"}]}` + err = os.WriteFile(filepath.Join(abiDir, "JsonContract.json"), []byte(jsonContent), 0600) + require.NoError(t, err) + originalDir, err := os.Getwd() require.NoError(t, err) defer func() { _ = os.Chdir(originalDir) }() @@ -388,6 +392,8 @@ func TestEndToEnd_TypeScriptGeneration(t *testing.T) { tsOutDir := filepath.Join(tempDir, "contracts", "evm", "ts", "generated") require.FileExists(t, filepath.Join(tsOutDir, "SimpleContract.ts")) require.FileExists(t, filepath.Join(tsOutDir, "SimpleContract_mock.ts")) + require.FileExists(t, filepath.Join(tsOutDir, "JsonContract.ts")) + require.FileExists(t, filepath.Join(tsOutDir, "JsonContract_mock.ts")) require.FileExists(t, filepath.Join(tsOutDir, "index.ts")) } @@ -548,6 +554,27 @@ func TestValidateInputs_ValidInputs(t *testing.T) { err = handler2.ValidateInputs(tsInputs) require.NoError(t, err) assert.True(t, handler2.validated) + + // Test validation with directory containing only .json files + abiDir3 := filepath.Join(tempDir, "abi_json") + err = os.MkdirAll(abiDir3, 0755) + require.NoError(t, err) + jsonContent := `{"abi":[{"type":"function","name":"test","inputs":[],"outputs":[]}]}` + err = os.WriteFile(filepath.Join(abiDir3, "Contract.json"), []byte(jsonContent), 0600) + require.NoError(t, err) + + jsonInputs := Inputs{ + ProjectRoot: tempDir, + ChainFamily: "evm", + GoLang: true, + AbiPath: abiDir3, + PkgName: "bindings", + GoOutPath: filepath.Join(tempDir, "out"), + } + handler3 := newHandler(runtimeCtx) + err = handler3.ValidateInputs(jsonInputs) + require.NoError(t, err) + assert.True(t, handler3.validated) } func TestValidateInputs_InvalidChainFamily(t *testing.T) { @@ -630,12 +657,15 @@ func TestProcessAbiDirectory_MultipleFiles(t *testing.T) { err = os.MkdirAll(abiDir, 0755) require.NoError(t, err) - // Create mock ABI files + // Create mock ABI files (both .abi and .json formats) abiContent := `[{"type":"function","name":"test","inputs":[],"outputs":[]}]` + jsonContent := `{"abi":[{"type":"function","name":"test","inputs":[],"outputs":[]}]}` err = os.WriteFile(filepath.Join(abiDir, "Contract1.abi"), []byte(abiContent), 0600) require.NoError(t, err) err = os.WriteFile(filepath.Join(abiDir, "Contract2.abi"), []byte(abiContent), 0600) require.NoError(t, err) + err = os.WriteFile(filepath.Join(abiDir, "Contract3.json"), []byte(jsonContent), 0600) + require.NoError(t, err) // Create a mock logger to prevent nil pointer dereference logger := zerolog.New(os.Stderr).With().Timestamp().Logger() @@ -667,8 +697,10 @@ func TestProcessAbiDirectory_MultipleFiles(t *testing.T) { // Verify that per-contract directories were created contract1Dir := filepath.Join(outDir, "contract1") contract2Dir := filepath.Join(outDir, "contract2") + contract3Dir := filepath.Join(outDir, "contract3") assert.DirExists(t, contract1Dir) assert.DirExists(t, contract2Dir) + assert.DirExists(t, contract3Dir) } func TestProcessAbiDirectory_CreatesPerContractDirectories(t *testing.T) { @@ -683,8 +715,9 @@ func TestProcessAbiDirectory_CreatesPerContractDirectories(t *testing.T) { err = os.MkdirAll(abiDir, 0755) require.NoError(t, err) - // Create mock ABI files with different naming patterns + // Create mock ABI files with different naming patterns (both .abi and .json) abiContent := `[{"type":"function","name":"test","inputs":[],"outputs":[]}]` + jsonContent := `{"abi":[{"type":"function","name":"test","inputs":[],"outputs":[]}]}` testCases := []struct { filename string expectedPackage string @@ -692,10 +725,15 @@ func TestProcessAbiDirectory_CreatesPerContractDirectories(t *testing.T) { {"IERC20.abi", "ierc20"}, {"ReserveManager.abi", "reserve_manager"}, {"SimpleERC20.abi", "simple_erc20"}, + {"MyToken.json", "my_token"}, } for _, tc := range testCases { - err = os.WriteFile(filepath.Join(abiDir, tc.filename), []byte(abiContent), 0600) + content := abiContent + if filepath.Ext(tc.filename) == ".json" { + content = jsonContent + } + err = os.WriteFile(filepath.Join(abiDir, tc.filename), []byte(content), 0600) require.NoError(t, err) } @@ -757,7 +795,7 @@ func TestProcessAbiDirectory_NoAbiFiles(t *testing.T) { err = handler.processAbiDirectory(inputs) require.Error(t, err) - assert.Contains(t, err.Error(), "no *.abi files found") + assert.Contains(t, err.Error(), "no *.abi or *.json files found") } func TestProcessAbiDirectory_NoAbiFiles_TypeScript(t *testing.T) { @@ -785,7 +823,7 @@ func TestProcessAbiDirectory_NoAbiFiles_TypeScript(t *testing.T) { err = handler.processAbiDirectory(inputs) require.Error(t, err) - assert.Contains(t, err.Error(), "no *.abi files found") + assert.Contains(t, err.Error(), "no *.abi or *.json files found") } func TestProcessAbiDirectory_PackageNameCollision(t *testing.T) { @@ -829,6 +867,42 @@ func TestProcessAbiDirectory_PackageNameCollision(t *testing.T) { require.Equal(t, err.Error(), "package name collision: multiple contracts would generate the same package name 'test_contract' (contracts are converted to snake_case for package names). Please rename one of your contract files to avoid this conflict") } +func TestProcessAbiDirectory_DuplicateContractNameAcrossExtensions(t *testing.T) { + tempDir, err := os.MkdirTemp("", "generate-bindings-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + abiDir := filepath.Join(tempDir, "abi") + outDir := filepath.Join(tempDir, "generated") + err = os.MkdirAll(abiDir, 0755) + require.NoError(t, err) + + abiContent := `[{"type":"function","name":"test","inputs":[],"outputs":[]}]` + jsonContent := `{"abi":[{"type":"function","name":"test","inputs":[],"outputs":[]}]}` + err = os.WriteFile(filepath.Join(abiDir, "Token.abi"), []byte(abiContent), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(abiDir, "Token.json"), []byte(jsonContent), 0600) + require.NoError(t, err) + + logger := zerolog.New(os.Stderr).With().Timestamp().Logger() + runtimeCtx := &runtime.Context{Logger: &logger} + handler := newHandler(runtimeCtx) + + inputs := Inputs{ + ProjectRoot: tempDir, + ChainFamily: "evm", + GoLang: true, + AbiPath: abiDir, + PkgName: "bindings", + GoOutPath: outDir, + } + + err = handler.processAbiDirectory(inputs) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate contract name") + assert.Contains(t, err.Error(), "Token") +} + func TestProcessAbiDirectory_NonExistentDirectory(t *testing.T) { logger := zerolog.New(os.Stderr).With().Timestamp().Logger() runtimeCtx := &runtime.Context{ @@ -847,15 +921,16 @@ func TestProcessAbiDirectory_NonExistentDirectory(t *testing.T) { err := handler.processAbiDirectory(inputs) require.Error(t, err) - assert.Contains(t, err.Error(), "no *.abi files found") + assert.Contains(t, err.Error(), "no *.abi or *.json files found") } // TestGenerateBindings_UnconventionalNaming tests binding generation for contracts // with unconventional naming patterns to verify correct handling or appropriate errors. +// Each case is run for both .abi (raw array) and .json (artifact with "abi" field) formats. func TestGenerateBindings_UnconventionalNaming(t *testing.T) { tests := []struct { name string - contractABI string + contractABI string // raw ABI JSON array pkgName string typeName string shouldFail bool @@ -951,30 +1026,41 @@ func TestGenerateBindings_UnconventionalNaming(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - tempDir, err := os.MkdirTemp("", "bindings-unconventional-test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - abiFile := filepath.Join(tempDir, tc.typeName+".abi") - err = os.WriteFile(abiFile, []byte(tc.contractABI), 0600) - require.NoError(t, err) - - outFile := filepath.Join(tempDir, "bindings.go") - err = bindings.GenerateBindings("", abiFile, tc.pkgName, tc.typeName, outFile) - - if tc.shouldFail { - require.Error(t, err, "Expected binding generation to fail for %s", tc.name) - if tc.expectedErrMsg != "" { - assert.Contains(t, err.Error(), tc.expectedErrMsg, "Error message should contain expected text") - } - } else { - require.NoError(t, err, "Binding generation should succeed for %s", tc.name) - - content, err := os.ReadFile(outFile) - require.NoError(t, err) - assert.NotEmpty(t, content, "Generated bindings should not be empty") - - assert.Contains(t, string(content), fmt.Sprintf("package %s", tc.pkgName)) + for _, ext := range []string{".abi", ".json"} { + t.Run(ext, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "bindings-unconventional-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + var fileContent string + if ext == ".json" { + fileContent = fmt.Sprintf(`{"abi":%s}`, tc.contractABI) + } else { + fileContent = tc.contractABI + } + + abiFile := filepath.Join(tempDir, tc.typeName+ext) + err = os.WriteFile(abiFile, []byte(fileContent), 0600) + require.NoError(t, err) + + outFile := filepath.Join(tempDir, "bindings.go") + err = bindings.GenerateBindings("", abiFile, tc.pkgName, tc.typeName, outFile) + + if tc.shouldFail { + require.Error(t, err, "Expected binding generation to fail for %s", tc.name) + if tc.expectedErrMsg != "" { + assert.Contains(t, err.Error(), tc.expectedErrMsg, "Error message should contain expected text") + } + } else { + require.NoError(t, err, "Binding generation should succeed for %s", tc.name) + + content, err := os.ReadFile(outFile) + require.NoError(t, err) + assert.NotEmpty(t, content, "Generated bindings should not be empty") + + assert.Contains(t, string(content), fmt.Sprintf("package %s", tc.pkgName)) + } + }) } }) } diff --git a/docs/cre_generate-bindings.md b/docs/cre_generate-bindings.md index c23cfc76..545a5d66 100644 --- a/docs/cre_generate-bindings.md +++ b/docs/cre_generate-bindings.md @@ -11,6 +11,9 @@ specified explicitly with --language. Each contract gets its own package subdirectory to avoid naming conflicts. For example, IERC20.abi generates bindings in generated/ierc20/ package. +Both raw ABI files (*.abi) and JSON artifact files (*.json) are supported. +For JSON files the ABI is read from the top-level "abi" field. + ``` cre generate-bindings [optional flags] ``` @@ -24,7 +27,7 @@ cre generate-bindings [optional flags] ### Options ``` - -a, --abi string Path to ABI directory (defaults to contracts/{chain-family}/src/abi/) + -a, --abi string Path to ABI directory (defaults to contracts/{chain-family}/src/abi/). Supports *.abi and *.json files -h, --help help for generate-bindings -l, --language string Target language: go, typescript (auto-detected from project files when omitted) -k, --pkg string Base package name (each contract gets its own subdirectory) (default "bindings")