-
Notifications
You must be signed in to change notification settings - Fork 3
[CLD-1777]: feat(operations-gen): add evm support #910
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| # operations-gen | ||
|
|
||
| Generates type-safe Go operation wrappers for smart contracts from their ABIs. | ||
|
|
||
| ## Usage | ||
|
|
||
| ```bash | ||
| go run ./tools/operations-gen -config /path/to/operations_gen_config.yaml | ||
| ``` | ||
|
|
||
| The `-config` path can be absolute or relative to the current working directory. | ||
graham-chainlink marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
graham-chainlink marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ## Project structure | ||
|
|
||
| ```text | ||
| tools/operations-gen/ | ||
| main.go # CLI entrypoint + chain-family dispatch | ||
| templates/ | ||
| evm/ | ||
| operations.tmpl # EVM codegen template | ||
| internal/ | ||
| core/ | ||
| core.go # Shared config + helpers/interfaces | ||
| families/ | ||
| evm/ | ||
| evm.go # EVM handler implementation | ||
| evm_test.go # EVM unit tests | ||
| evm_golden_test.go # End-to-end golden generation tests | ||
| testdata/ | ||
| evm/ # ABI/bytecode/config/golden fixtures | ||
graham-chainlink marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ``` | ||
|
|
||
| `main.go` intentionally stays thin: it parses top-level config, loads the template for the selected chain family, and dispatches to the family handler. Shared helpers and common config types live in `internal/core`. | ||
|
|
||
| ## Configuration | ||
|
|
||
| Create an `operations_gen_config.yaml` alongside your ABI/bytecode directories: | ||
|
|
||
| ```yaml | ||
| version: "1.0.0" | ||
| chain_family: evm # Optional: defaults to "evm" | ||
|
|
||
| input: | ||
| abi_base_path: "./abi" # Directory containing versioned ABI json files | ||
| bytecode_base_path: "./bytecode" # Directory containing versioned bytecode .bin files | ||
|
|
||
| output: | ||
| base_path: "." # Directory where generated operations/ folders are written | ||
|
Comment on lines
+43
to
+48
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we always assume that the contracts will be in a single base path?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmmm, hard to say, but users can move them into a single base path if it is not? Or do you think is it worth supporting multiple base path? I was also thinking to avoid many breaking user facing changes with the existing cli too.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not concerned about breaking changes so much. We are providing this as a feature for the teams that want to use it. CCIP can choose to switch to it or continue to use their own. I would like to make sure this is flexible though. I could see a case where users do not put all their abis and byte code in a single path. Maybe I'm over thinking this though
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, not a big lift, i can do that for the extra flexibility |
||
|
|
||
| contracts: | ||
| - contract_name: FeeQuoter | ||
| version: "1.6.0" | ||
| package_name: fee_quoter # Optional: override default package name | ||
| abi_file: "fee_quoter.json" # Optional: override default ABI filename | ||
| omit_deploy: false # Optional: set true to skip Deploy operation generation (default: false) | ||
| functions: | ||
| - name: updatePrices | ||
| access: owner # Write op with MCMS support | ||
| - name: getTokenPrice | ||
| access: public # Read op (or public write op) | ||
| ``` | ||
|
|
||
| ### Top-level fields | ||
|
|
||
| | Field | Required | Description | | ||
| | ------------------ | -------- | --------------------------------------------------------------------------------------------- | | ||
| | `version` | Yes | Config schema version | | ||
| | `chain_family` | No | Target chain family. Only `"evm"` is supported. Defaults to `"evm"`. | | ||
| | `input.abi_base_path` | Yes | Directory containing versioned ABI files. Relative to the config file. | | ||
| | `input.bytecode_base_path` | Yes | Directory containing versioned bytecode files. Relative to the config file. | | ||
| | `output.base_path` | Yes | Root directory where generated files are written. Relative to the config file. | | ||
|
|
||
graham-chainlink marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ### Contract fields | ||
|
|
||
| | Field | Required | Description | | ||
| | --------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | | ||
| | `contract_name` | Yes | Contract name as it appears in the ABI (e.g. `FeeQuoter`) | | ||
| | `version` | Yes | Semver version of the contract (e.g. `"1.6.0"`) | | ||
| | `package_name` | No | Override the generated Go package name. Defaults to `snake_case(contract_name)`. | | ||
| | `abi_file` | No | Override the ABI filename. Defaults to `{package_name}.json`. | | ||
| | `version_path` | No | Override the directory path derived from the version. Defaults to `v{major}_{minor}_{patch}`. | | ||
| | `omit_deploy` | No | Skip generation of the `Deploy` operation and bytecode constant. Defaults to `false`. | | ||
|
|
||
| ### Function access control | ||
|
|
||
| | Value | Behaviour | | ||
| | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `owner` | Generates a write operation gated by `OnlyOwner`, producing an MCMS-compatible transaction when the deployer key is not the owner. | | ||
| | `public` | Generates a read operation (for `view`/`pure` functions) or an unrestricted write operation. | | ||
|
|
||
| ## Input layout | ||
|
|
||
| The generator expects ABIs and bytecode under separate input roots: | ||
|
|
||
| ``` | ||
| {input.abi_base_path}/ | ||
| v1_6_0/ | ||
| fee_quoter.json | ||
|
|
||
| {input.bytecode_base_path}/ | ||
| v1_6_0/ | ||
| fee_quoter.bin | ||
| ``` | ||
|
|
||
| Version `1.6.0` maps to directory `v1_6_0`. Override with `version_path` if your layout differs. | ||
|
|
||
| ## Output layout | ||
|
|
||
| Generated files are written to: | ||
|
|
||
| ``` | ||
| {output.base_path}/ | ||
| v1_6_0/ | ||
| operations/ | ||
| fee_quoter/ | ||
| fee_quoter.go | ||
| ``` | ||
|
|
||
| Each generated file contains: | ||
|
|
||
| - ABI and bytecode constants | ||
| - A bound contract wrapper with typed methods | ||
| - A `Deploy` operation (unless `omit_deploy: true`) | ||
| - A typed write operation for each `access: owner` or writable `access: public` function | ||
| - A typed read operation for each `view`/`pure` function | ||
|
|
||
| The generated code imports the runtime helpers from: | ||
|
|
||
| ``` | ||
| github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract | ||
| ``` | ||
|
|
||
| ## Extending to new chain families | ||
|
|
||
| > Only `evm` is supported today. The steps below describe how to add support for a new family in the future. | ||
|
|
||
| The generator dispatches entirely by `chain_family`. Each family owns its own YAML contract schema, type mappings, template, and generation logic; only common CLI/config plumbing and dispatch utilities are shared. | ||
|
|
||
| To add a new chain family (e.g. `solana`): | ||
|
|
||
| 1. Create `internal/families/solana/solana.go` with a `solana.Handler` type implementing `core.ChainFamilyHandler`: | ||
|
|
||
| ```go | ||
| type ChainFamilyHandler interface { | ||
| Generate(config core.Config, tmpl *template.Template) error | ||
| } | ||
| ``` | ||
|
|
||
| The handler receives the full `core.Config`. `Config.Input`, `Config.Output`, and `Config.Contracts` are `yaml.Node` values so each chain-family handler can decode its own chain-specific schemas. | ||
|
|
||
| 2. Add `templates/solana/operations.tmpl` with chain-appropriate imports and method bodies. | ||
|
|
||
| 3. Register the handler in `chainFamilies` in `main.go`: | ||
| ```go | ||
| var chainFamilies = map[string]core.ChainFamilyHandler{ | ||
| "evm": evm.Handler{}, | ||
| "solana": solana.Handler{}, | ||
| } | ||
| ``` | ||
|
|
||
| No other changes to `main.go` are needed. Set `chain_family: solana` in your config to use it. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package core | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "go/format" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "text/template" | ||
|
|
||
| "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| // ChainFamilyHandler abstracts all chain-specific generation logic. | ||
| type ChainFamilyHandler interface { | ||
| Generate(config Config, tmpl *template.Template) error | ||
| } | ||
|
|
||
| // Config holds the top-level generator configuration. | ||
| // Input/Output/Contracts are raw YAML nodes so handlers own their own schemas. | ||
| type Config struct { | ||
| Version string `yaml:"version"` | ||
| ChainFamily string `yaml:"chain_family"` // defaults to "evm" | ||
| Input yaml.Node `yaml:"input"` | ||
| Output yaml.Node `yaml:"output"` | ||
| Contracts yaml.Node `yaml:"contracts"` | ||
| ConfigDir string `yaml:"-"` | ||
| } | ||
|
|
||
| // WriteGoFile formats src as Go source and writes it to path, creating parent directories. | ||
| func WriteGoFile(path string, src []byte) error { | ||
| formatted, err := format.Source(src) | ||
| if err != nil { | ||
| return fmt.Errorf("formatting error: %w\n%s", err, src) | ||
| } | ||
| if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | ||
| return fmt.Errorf("failed to create output directory: %w", err) | ||
| } | ||
| if err := os.WriteFile(path, formatted, 0o600); err != nil { | ||
| return fmt.Errorf("failed to write file: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // VersionToPath converts a semver string to a directory path segment. | ||
| func VersionToPath(version string) string { | ||
| return "v" + strings.ReplaceAll(version, ".", "_") | ||
| } | ||
|
|
||
| // ContractOutputPath builds the output file path for a generated contract operations file. | ||
| func ContractOutputPath(basePath, versionPath, packageName string) string { | ||
| return filepath.Join(basePath, versionPath, "operations", packageName, packageName+".go") | ||
| } | ||
|
|
||
| // Capitalize uppercases the first character of s. | ||
| func Capitalize(s string) string { | ||
| if s == "" { | ||
| return "" | ||
| } | ||
|
|
||
| return strings.ToUpper(s[:1]) + s[1:] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package core | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestVersionToPath verifies the semver-to-directory-path conversion. | ||
| func TestVersionToPath(t *testing.T) { | ||
| t.Parallel() | ||
| cases := []struct{ version, want string }{ | ||
| {"1.0.0", "v1_0_0"}, | ||
| {"1.2.3", "v1_2_3"}, | ||
| {"0.0.1", "v0_0_1"}, | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.version, func(t *testing.T) { | ||
| t.Parallel() | ||
| if got := VersionToPath(tc.version); got != tc.want { | ||
| t.Errorf("VersionToPath(%q) = %q, want %q", tc.version, got, tc.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestCapitalize(t *testing.T) { | ||
| t.Parallel() | ||
| cases := []struct { | ||
| input, want string | ||
| }{ | ||
| {"", ""}, | ||
| {"hello", "Hello"}, | ||
| {"Hello", "Hello"}, | ||
| {"hELLO", "HELLO"}, | ||
| {"a", "A"}, | ||
| {"1abc", "1abc"}, // non-alpha first char is left as-is | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.input, func(t *testing.T) { | ||
| t.Parallel() | ||
| if got := Capitalize(tc.input); got != tc.want { | ||
| t.Errorf("Capitalize(%q) = %q, want %q", tc.input, got, tc.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestWriteGoFileInvalidSource(t *testing.T) { | ||
| t.Parallel() | ||
| dir := t.TempDir() | ||
| err := WriteGoFile(filepath.Join(dir, "out.go"), []byte("this is not valid Go }{")) | ||
| if err == nil { | ||
| t.Fatal("expected formatting error for invalid Go source, got nil") | ||
| } | ||
| } | ||
|
|
||
| func TestWriteGoFileWritesFormattedFile(t *testing.T) { | ||
| t.Parallel() | ||
| dir := t.TempDir() | ||
| src := []byte("package foo\nfunc F(){}\n") | ||
| outPath := filepath.Join(dir, "sub", "out.go") | ||
|
|
||
| if err := WriteGoFile(outPath, src); err != nil { | ||
| t.Fatalf("WriteGoFile: %v", err) | ||
| } | ||
|
|
||
| got, err := os.ReadFile(outPath) | ||
| if err != nil { | ||
| t.Fatalf("reading output: %v", err) | ||
| } | ||
| if len(got) == 0 { | ||
| t.Error("expected non-empty output file") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed per discussion as taskfile is more of a repo wide command