Skip to content

Commit 4e3e764

Browse files
[CLD-1777]: feat(operations-gen): add evm support (#910)
Add evm support for the operations-gen cli, ported the code from https://github.com/smartcontractkit/chainlink-ccip/blob/main/chains/evm/cmd/operations-gen/main.go, improved and adapted it to the new design I have follow up PR adding some golden tests - #920 JIRA: https://smartcontract-it.atlassian.net/browse/CLD-1777
1 parent 5142a85 commit 4e3e764

10 files changed

Lines changed: 1501 additions & 171 deletions

File tree

taskfiles/generate/Taskfile.yml

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,3 @@ tasks:
99
desc: Generate mocks for interfaces
1010
cmds:
1111
- mockery
12-
13-
operations:
14-
desc: Generate contract operation wrappers from a config file
15-
summary: |
16-
Generates type-safe Go operation wrappers for EVM (or other chain family) contracts.
17-
18-
Pass CONFIG variable with an absolute path, or a path relative to the repo root.
19-
Relative paths are resolved against the repo root before being passed to the generator.
20-
21-
Example:
22-
task generate:operations CONFIG=/path/to/operations_gen_config.yaml
23-
task generate:operations CONFIG=./path/from/repo/root/operations_gen_config.yaml
24-
vars:
25-
CONFIG: '{{.CONFIG}}'
26-
# If CONFIG is absolute use it as-is; otherwise treat it as relative to the repo root.
27-
# This avoids a dependency on `realpath` which is not available on all platforms.
28-
ABS_CONFIG:
29-
sh: |
30-
case "{{.CONFIG}}" in
31-
/*) printf '%s\n' "{{.CONFIG}}" ;;
32-
*) printf '%s\n' "{{.ROOT_DIR}}/{{.CONFIG}}" ;;
33-
esac
34-
cmds:
35-
- cd {{.ROOT_DIR}}/tools/operations-gen && go run . -config {{.ABS_CONFIG}}

tools/operations-gen/README.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# operations-gen
2+
3+
Generates type-safe Go operation wrappers for smart contracts from their ABIs.
4+
5+
## Usage
6+
7+
```bash
8+
go run ./tools/operations-gen -config /path/to/operations_gen_config.yaml
9+
```
10+
11+
The `-config` path can be absolute or relative to the current working directory.
12+
13+
## Project structure
14+
15+
```text
16+
tools/operations-gen/
17+
main.go # CLI entrypoint + chain-family dispatch
18+
templates/
19+
evm/
20+
operations.tmpl # EVM codegen template
21+
internal/
22+
core/
23+
core.go # Shared config + helpers/interfaces
24+
families/
25+
evm/
26+
evm.go # EVM handler implementation
27+
evm_test.go # EVM unit tests
28+
evm_golden_test.go # End-to-end golden generation tests
29+
testdata/
30+
evm/ # ABI/bytecode/config/golden fixtures
31+
```
32+
33+
`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`.
34+
35+
## Configuration
36+
37+
Create an `operations_gen_config.yaml` alongside your ABI/bytecode directories:
38+
39+
```yaml
40+
version: "1.0.0"
41+
chain_family: evm # Optional: defaults to "evm"
42+
43+
input:
44+
abi_base_path: "./abi" # Directory containing versioned ABI json files
45+
bytecode_base_path: "./bytecode" # Directory containing versioned bytecode .bin files
46+
47+
output:
48+
base_path: "." # Directory where generated operations/ folders are written
49+
50+
contracts:
51+
- contract_name: FeeQuoter
52+
version: "1.6.0"
53+
package_name: fee_quoter # Optional: override default package name
54+
abi_file: "fee_quoter.json" # Optional: override default ABI filename
55+
omit_deploy: false # Optional: set true to skip Deploy operation generation (default: false)
56+
functions:
57+
- name: updatePrices
58+
access: owner # Write op with MCMS support
59+
- name: getTokenPrice
60+
access: public # Read op (or public write op)
61+
```
62+
63+
### Top-level fields
64+
65+
| Field | Required | Description |
66+
| ------------------ | -------- | --------------------------------------------------------------------------------------------- |
67+
| `version` | Yes | Config schema version |
68+
| `chain_family` | No | Target chain family. Only `"evm"` is supported. Defaults to `"evm"`. |
69+
| `input.abi_base_path` | Yes | Directory containing versioned ABI files. Relative to the config file. |
70+
| `input.bytecode_base_path` | Yes | Directory containing versioned bytecode files. Relative to the config file. |
71+
| `output.base_path` | Yes | Root directory where generated files are written. Relative to the config file. |
72+
73+
### Contract fields
74+
75+
| Field | Required | Description |
76+
| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
77+
| `contract_name` | Yes | Contract name as it appears in the ABI (e.g. `FeeQuoter`) |
78+
| `version` | Yes | Semver version of the contract (e.g. `"1.6.0"`) |
79+
| `package_name` | No | Override the generated Go package name. Defaults to `snake_case(contract_name)`. |
80+
| `abi_file` | No | Override the ABI filename. Defaults to `{package_name}.json`. |
81+
| `version_path` | No | Override the directory path derived from the version. Defaults to `v{major}_{minor}_{patch}`. |
82+
| `omit_deploy` | No | Skip generation of the `Deploy` operation and bytecode constant. Defaults to `false`. |
83+
84+
### Function access control
85+
86+
| Value | Behaviour |
87+
| -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
88+
| `owner` | Generates a write operation gated by `OnlyOwner`, producing an MCMS-compatible transaction when the deployer key is not the owner. |
89+
| `public` | Generates a read operation (for `view`/`pure` functions) or an unrestricted write operation. |
90+
91+
## Input layout
92+
93+
The generator expects ABIs and bytecode under separate input roots:
94+
95+
```
96+
{input.abi_base_path}/
97+
v1_6_0/
98+
fee_quoter.json
99+
100+
{input.bytecode_base_path}/
101+
v1_6_0/
102+
fee_quoter.bin
103+
```
104+
105+
Version `1.6.0` maps to directory `v1_6_0`. Override with `version_path` if your layout differs.
106+
107+
## Output layout
108+
109+
Generated files are written to:
110+
111+
```
112+
{output.base_path}/
113+
v1_6_0/
114+
operations/
115+
fee_quoter/
116+
fee_quoter.go
117+
```
118+
119+
Each generated file contains:
120+
121+
- ABI and bytecode constants
122+
- A bound contract wrapper with typed methods
123+
- A `Deploy` operation (unless `omit_deploy: true`)
124+
- A typed write operation for each `access: owner` or writable `access: public` function
125+
- A typed read operation for each `view`/`pure` function
126+
127+
The generated code imports the runtime helpers from:
128+
129+
```
130+
github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract
131+
```
132+
133+
## Extending to new chain families
134+
135+
> Only `evm` is supported today. The steps below describe how to add support for a new family in the future.
136+
137+
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.
138+
139+
To add a new chain family (e.g. `solana`):
140+
141+
1. Create `internal/families/solana/solana.go` with a `solana.Handler` type implementing `core.ChainFamilyHandler`:
142+
143+
```go
144+
type ChainFamilyHandler interface {
145+
Generate(config core.Config, tmpl *template.Template) error
146+
}
147+
```
148+
149+
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.
150+
151+
2. Add `templates/solana/operations.tmpl` with chain-appropriate imports and method bodies.
152+
153+
3. Register the handler in `chainFamilies` in `main.go`:
154+
```go
155+
var chainFamilies = map[string]core.ChainFamilyHandler{
156+
"evm": evm.Handler{},
157+
"solana": solana.Handler{},
158+
}
159+
```
160+
161+
No other changes to `main.go` are needed. Set `chain_family: solana` in your config to use it.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package core
2+
3+
import (
4+
"fmt"
5+
"go/format"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"text/template"
10+
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
// ChainFamilyHandler abstracts all chain-specific generation logic.
15+
type ChainFamilyHandler interface {
16+
Generate(config Config, tmpl *template.Template) error
17+
}
18+
19+
// Config holds the top-level generator configuration.
20+
// Input/Output/Contracts are raw YAML nodes so handlers own their own schemas.
21+
type Config struct {
22+
Version string `yaml:"version"`
23+
ChainFamily string `yaml:"chain_family"` // defaults to "evm"
24+
Input yaml.Node `yaml:"input"`
25+
Output yaml.Node `yaml:"output"`
26+
Contracts yaml.Node `yaml:"contracts"`
27+
ConfigDir string `yaml:"-"`
28+
}
29+
30+
// WriteGoFile formats src as Go source and writes it to path, creating parent directories.
31+
func WriteGoFile(path string, src []byte) error {
32+
formatted, err := format.Source(src)
33+
if err != nil {
34+
return fmt.Errorf("formatting error: %w\n%s", err, src)
35+
}
36+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
37+
return fmt.Errorf("failed to create output directory: %w", err)
38+
}
39+
if err := os.WriteFile(path, formatted, 0o600); err != nil {
40+
return fmt.Errorf("failed to write file: %w", err)
41+
}
42+
43+
return nil
44+
}
45+
46+
// VersionToPath converts a semver string to a directory path segment.
47+
func VersionToPath(version string) string {
48+
return "v" + strings.ReplaceAll(version, ".", "_")
49+
}
50+
51+
// ContractOutputPath builds the output file path for a generated contract operations file.
52+
func ContractOutputPath(basePath, versionPath, packageName string) string {
53+
return filepath.Join(basePath, versionPath, "operations", packageName, packageName+".go")
54+
}
55+
56+
// Capitalize uppercases the first character of s.
57+
func Capitalize(s string) string {
58+
if s == "" {
59+
return ""
60+
}
61+
62+
return strings.ToUpper(s[:1]) + s[1:]
63+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package core
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// TestVersionToPath verifies the semver-to-directory-path conversion.
10+
func TestVersionToPath(t *testing.T) {
11+
t.Parallel()
12+
cases := []struct{ version, want string }{
13+
{"1.0.0", "v1_0_0"},
14+
{"1.2.3", "v1_2_3"},
15+
{"0.0.1", "v0_0_1"},
16+
}
17+
for _, tc := range cases {
18+
t.Run(tc.version, func(t *testing.T) {
19+
t.Parallel()
20+
if got := VersionToPath(tc.version); got != tc.want {
21+
t.Errorf("VersionToPath(%q) = %q, want %q", tc.version, got, tc.want)
22+
}
23+
})
24+
}
25+
}
26+
27+
func TestCapitalize(t *testing.T) {
28+
t.Parallel()
29+
cases := []struct {
30+
input, want string
31+
}{
32+
{"", ""},
33+
{"hello", "Hello"},
34+
{"Hello", "Hello"},
35+
{"hELLO", "HELLO"},
36+
{"a", "A"},
37+
{"1abc", "1abc"}, // non-alpha first char is left as-is
38+
}
39+
for _, tc := range cases {
40+
t.Run(tc.input, func(t *testing.T) {
41+
t.Parallel()
42+
if got := Capitalize(tc.input); got != tc.want {
43+
t.Errorf("Capitalize(%q) = %q, want %q", tc.input, got, tc.want)
44+
}
45+
})
46+
}
47+
}
48+
49+
func TestWriteGoFileInvalidSource(t *testing.T) {
50+
t.Parallel()
51+
dir := t.TempDir()
52+
err := WriteGoFile(filepath.Join(dir, "out.go"), []byte("this is not valid Go }{"))
53+
if err == nil {
54+
t.Fatal("expected formatting error for invalid Go source, got nil")
55+
}
56+
}
57+
58+
func TestWriteGoFileWritesFormattedFile(t *testing.T) {
59+
t.Parallel()
60+
dir := t.TempDir()
61+
src := []byte("package foo\nfunc F(){}\n")
62+
outPath := filepath.Join(dir, "sub", "out.go")
63+
64+
if err := WriteGoFile(outPath, src); err != nil {
65+
t.Fatalf("WriteGoFile: %v", err)
66+
}
67+
68+
got, err := os.ReadFile(outPath)
69+
if err != nil {
70+
t.Fatalf("reading output: %v", err)
71+
}
72+
if len(got) == 0 {
73+
t.Error("expected non-empty output file")
74+
}
75+
}

0 commit comments

Comments
 (0)