Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions taskfiles/generate/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,3 @@ tasks:
desc: Generate mocks for interfaces
cmds:
- mockery

Copy link
Copy Markdown
Collaborator Author

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

operations:
desc: Generate contract operation wrappers from a config file
summary: |
Generates type-safe Go operation wrappers for EVM (or other chain family) contracts.

Pass CONFIG variable with an absolute path, or a path relative to the repo root.
Relative paths are resolved against the repo root before being passed to the generator.

Example:
task generate:operations CONFIG=/path/to/operations_gen_config.yaml
task generate:operations CONFIG=./path/from/repo/root/operations_gen_config.yaml
vars:
CONFIG: '{{.CONFIG}}'
# If CONFIG is absolute use it as-is; otherwise treat it as relative to the repo root.
# This avoids a dependency on `realpath` which is not available on all platforms.
ABS_CONFIG:
sh: |
case "{{.CONFIG}}" in
/*) printf '%s\n' "{{.CONFIG}}" ;;
*) printf '%s\n' "{{.ROOT_DIR}}/{{.CONFIG}}" ;;
esac
cmds:
- cd {{.ROOT_DIR}}/tools/operations-gen && go run . -config {{.ABS_CONFIG}}
161 changes: 161 additions & 0 deletions tools/operations-gen/README.md
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.

## 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
```

`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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator Author

@graham-chainlink graham-chainlink Apr 13, 2026

Choose a reason for hiding this comment

The 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. |

### 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.
63 changes: 63 additions & 0 deletions tools/operations-gen/internal/core/core.go
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:]
}
75 changes: 75 additions & 0 deletions tools/operations-gen/internal/core/core_test.go
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")
}
}
Loading
Loading