diff --git a/.github/workflows/add-new-evm-chain.yml b/.github/workflows/add-new-evm-chain.yml new file mode 100644 index 00000000..0859a074 --- /dev/null +++ b/.github/workflows/add-new-evm-chain.yml @@ -0,0 +1,254 @@ +name: Add New EVM Chain + +on: + workflow_dispatch: + inputs: + chain-selector: + description: 'The chain selector value (uint64)' + required: true + type: string + dry-run: + description: 'Dry run mode - show what would be done without making changes' + required: false + default: false + type: boolean + workflow_call: + inputs: + chain-selector: + description: 'The chain selector value (uint64)' + required: true + type: string + dry-run: + description: 'Dry run mode - show what would be done without making changes' + required: false + default: false + type: boolean + +permissions: + id-token: write + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ inputs.chain-selector }} + cancel-in-progress: true + +jobs: + add-new-evm-chain: + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + chain-name: ${{ steps.run-tool.outputs.chain_name }} + action: ${{ steps.run-tool.outputs.action }} + pr-url: ${{ steps.create-pr.outputs.pr_url }} + steps: + - name: Validate inputs + run: | + set -euo pipefail + + CHAIN_SELECTOR="${{ inputs.chain-selector }}" + + # Validate chain-selector is a positive integer (uint64) + if ! [[ "$CHAIN_SELECTOR" =~ ^[0-9]+$ ]]; then + echo "::error::chain-selector must be a positive integer, got: $CHAIN_SELECTOR" + exit 1 + fi + + # Validate it's within uint64 range (max: 18446744073709551615) + if [[ ${#CHAIN_SELECTOR} -gt 20 ]]; then + echo "::error::chain-selector exceeds uint64 maximum value" + exit 1 + fi + + echo "::notice::Input validation passed: chain-selector=$CHAIN_SELECTOR" + + - name: Assume AWS GATI role + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN_GATI }} + role-duration-seconds: 900 + aws-region: ${{ secrets.AWS_REGION }} + mask-aws-account-id: true + + - name: Get GitHub token from GATI + id: get-gh-token + uses: smartcontractkit/chainlink-github-actions/github-app-token-issuer@main + with: + url: ${{ secrets.AWS_LAMBDA_URL_GATI }} + + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + token: ${{ steps.get-gh-token.outputs.access-token }} + fetch-depth: 0 + + - name: Set tool versions to env vars + id: tool-versions + uses: smartcontractkit/tool-versions-to-env-action@aabd5efbaf28005284e846c5cf3a02f2cba2f4c2 # v1.0.8 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.0.0 + with: + go-version: ${{ steps.tool-versions.outputs.golang_version }} + + - name: Build add-new-evm-chain tool + run: | + set -euo pipefail + cd cre/go + go build -o add-new-evm-chain ./tools/add-new-evm-chain + + - name: Run add-new-evm-chain tool + id: run-tool + env: + INPUT_CHAIN_SELECTOR: ${{ inputs.chain-selector }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + cd cre/go + + # Build command arguments + ARGS="-chain-selector $INPUT_CHAIN_SELECTOR -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto" + if [[ "$INPUT_DRY_RUN" == "true" ]]; then + ARGS="$ARGS -dry-run" + fi + + # Run tool and capture output + echo "::group::Tool output" + if ! OUTPUT=$(./add-new-evm-chain $ARGS 2>&1); then + echo "$OUTPUT" + echo "::endgroup::" + + # Try to parse ACTION even on failure + ACTION=$(echo "$OUTPUT" | grep "^ACTION=" | cut -d'=' -f2 || echo "error") + echo "action=$ACTION" >> $GITHUB_OUTPUT + + echo "::error::Tool execution failed" + exit 1 + fi + echo "$OUTPUT" + echo "::endgroup::" + + # Parse structured output + CHAIN_NAME=$(echo "$OUTPUT" | grep "^CHAIN_NAME=" | cut -d'=' -f2) + CHAIN_SELECTOR=$(echo "$OUTPUT" | grep "^CHAIN_SELECTOR=" | cut -d'=' -f2) + ACTION=$(echo "$OUTPUT" | grep "^ACTION=" | cut -d'=' -f2) + + # Validate outputs + if [[ -z "$CHAIN_NAME" || -z "$ACTION" ]]; then + echo "::error::Failed to parse tool output - missing CHAIN_NAME or ACTION" + exit 1 + fi + + echo "chain_name=$CHAIN_NAME" >> $GITHUB_OUTPUT + echo "chain_selector=$CHAIN_SELECTOR" >> $GITHUB_OUTPUT + echo "action=$ACTION" >> $GITHUB_OUTPUT + + echo "::notice::Chain: $CHAIN_NAME, Selector: $CHAIN_SELECTOR, Action: $ACTION" + + - name: Sanitize chain name for branch + id: sanitize + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} + env: + CHAIN_NAME: ${{ steps.run-tool.outputs.chain_name }} + run: | + set -euo pipefail + + # Sanitize chain name for use in git branch names + # Only allow alphanumeric, hyphens, and underscores + SAFE_CHAIN_NAME=$(echo "$CHAIN_NAME" | tr -cd '[:alnum:]-_' | tr '[:upper:]' '[:lower:]') + + if [[ -z "$SAFE_CHAIN_NAME" ]]; then + echo "::error::Chain name sanitization resulted in empty string" + exit 1 + fi + + echo "safe_chain_name=$SAFE_CHAIN_NAME" >> $GITHUB_OUTPUT + echo "::notice::Sanitized chain name: $SAFE_CHAIN_NAME" + + - name: Create branch for PR + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} + id: create-branch + env: + SAFE_CHAIN_NAME: ${{ steps.sanitize.outputs.safe_chain_name }} + run: | + set -euo pipefail + + BRANCH_NAME="add-evm-chain/${SAFE_CHAIN_NAME}-$(date +%Y%m%d-%H%M%S)" + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + + git checkout -b "$BRANCH_NAME" + echo "::notice::Created branch: $BRANCH_NAME" + + - name: Commit changes using ghcommit + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} + uses: planetscale/ghcommit-action@v0.1.45 + with: + commit_message: "feat(cre): add EVM chain ${{ steps.run-tool.outputs.chain_name }}" + repo: ${{ github.repository }} + branch: ${{ steps.create-branch.outputs.branch_name }} + file_pattern: "cre/capabilities/blockchain/evm/v1alpha/client.proto" + env: + GITHUB_TOKEN: ${{ steps.get-gh-token.outputs.access-token }} + + - name: Create Pull Request + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} + id: create-pr + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + CHAIN_NAME: ${{ steps.run-tool.outputs.chain_name }} + CHAIN_SELECTOR: ${{ steps.run-tool.outputs.chain_selector }} + BRANCH_NAME: ${{ steps.create-branch.outputs.branch_name }} + with: + github-token: ${{ steps.get-gh-token.outputs.access-token }} + script: | + const chainName = process.env.CHAIN_NAME; + const chainSelector = process.env.CHAIN_SELECTOR; + const branchName = process.env.BRANCH_NAME; + + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `feat(cre): add EVM chain ${chainName}`, + head: branchName, + base: 'main', + body: `## Summary + + This PR adds the EVM chain **${chainName}** to the CRE client.proto file. + + ### Chain Details + - **Chain Name**: ${chainName} + - **Selector**: ${chainSelector} + + ### Changes + - Added entry to \`cre/capabilities/blockchain/evm/v1alpha/client.proto\` + + --- + *This PR was automatically generated by the add-new-evm-chain workflow.* + `, + maintainer_can_modify: true + }); + + core.setOutput('pr_url', pr.html_url); + core.notice(`Created PR: ${pr.html_url}`); + + - name: Summary + env: + CHAIN_NAME: ${{ steps.run-tool.outputs.chain_name }} + CHAIN_SELECTOR: ${{ steps.run-tool.outputs.chain_selector }} + ACTION: ${{ steps.run-tool.outputs.action }} + DRY_RUN: ${{ inputs.dry-run }} + PR_URL: ${{ steps.create-pr.outputs.pr_url }} + run: | + set -euo pipefail + + echo "## Add New EVM Chain Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Chain Name | $CHAIN_NAME |" >> $GITHUB_STEP_SUMMARY + echo "| Chain Selector | $CHAIN_SELECTOR |" >> $GITHUB_STEP_SUMMARY + echo "| Action | $ACTION |" >> $GITHUB_STEP_SUMMARY + echo "| Dry Run | $DRY_RUN |" >> $GITHUB_STEP_SUMMARY + if [[ -n "$PR_URL" ]]; then + echo "| PR URL | $PR_URL |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/cre/go/go.mod b/cre/go/go.mod index c417f2f3..1a1645cd 100644 --- a/cre/go/go.mod +++ b/cre/go/go.mod @@ -5,12 +5,14 @@ go 1.24.5 require ( github.com/go-viper/mapstructure/v2 v2.4.0 github.com/shopspring/decimal v1.4.0 + github.com/smartcontractkit/chain-selectors v1.0.85 github.com/stretchr/testify v1.10.0 google.golang.org/protobuf v1.36.7 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cre/go/go.sum b/cre/go/go.sum index e1ac1829..81f860a3 100644 --- a/cre/go/go.sum +++ b/cre/go/go.sum @@ -4,10 +4,14 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chain-selectors v1.0.85 h1:A7eyN7KIACxmngn1MJJ0giVtfELK9vh/eYyNU/Q/sFo= +github.com/smartcontractkit/chain-selectors v1.0.85/go.mod h1:xsKM0aN3YGcQKTPRPDDtPx2l4mlTN1Djmg0VVXV40b8= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= diff --git a/cre/go/tools/add-new-evm-chain/README.md b/cre/go/tools/add-new-evm-chain/README.md new file mode 100644 index 00000000..1095fdae --- /dev/null +++ b/cre/go/tools/add-new-evm-chain/README.md @@ -0,0 +1,181 @@ +# Add New EVM Chain Tool + +A CLI tool for adding new EVM chain selectors to the chainlink-protos EVM client.proto file. + +## Overview + +This tool automates the process of adding a new EVM chain to the CRE (Chainlink Runtime Environment) by: + +1. Looking up chain information from the [chain-selectors](https://github.com/smartcontractkit/chain-selectors) package +2. Validating that the chain is an EVM chain +3. Checking if the chain already exists in the proto file +4. Adding the chain entry to `cre/capabilities/blockchain/evm/v1alpha/client.proto` (sorted alphabetically) + +## Installation + +Build the tool from the `cre/go` directory: + +```bash +cd cre/go +go build -o add-new-evm-chain ./tools/add-new-evm-chain +``` + +## Usage + +### Basic Usage + +```bash +# From the cre/go directory +./add-new-evm-chain -chain-selector +``` + +### Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `-chain-selector` | Yes | - | The chain selector value (uint64) | +| `-proto-file` | No | Auto-detect | Path to client.proto file | +| `-dry-run` | No | `false` | Preview changes without modifying files | + +### Examples + +#### Dry Run (Preview Changes) + +```bash +./add-new-evm-chain -chain-selector 5009297550715157269 -dry-run +``` + +Output: +``` +Found chain: ethereum-mainnet (selector: 5009297550715157269) + +=== DRY RUN MODE === +Would add chain entry: + Key: ethereum-mainnet + Value: 5009297550715157269 +To file: ../capabilities/blockchain/evm/v1alpha/client.proto + +--- OUTPUT --- +CHAIN_NAME=ethereum-mainnet +CHAIN_SELECTOR=5009297550715157269 +ACTION=dry-run +``` + +#### Add a New Chain + +```bash +./add-new-evm-chain -chain-selector 5009297550715157269 +``` + +Output: +``` +Found chain: ethereum-mainnet (selector: 5009297550715157269) +Successfully added chain ethereum-mainnet to proto file + +--- OUTPUT --- +CHAIN_NAME=ethereum-mainnet +CHAIN_SELECTOR=5009297550715157269 +ACTION=added +``` + +#### Existing Chain + +If the chain already exists: +``` +Found chain: ethereum-mainnet (selector: 5009297550715157269) +Chain ethereum-mainnet already exists in proto file, nothing to do + +--- OUTPUT --- +CHAIN_NAME=ethereum-mainnet +CHAIN_SELECTOR=5009297550715157269 +ACTION=exists +``` + +## Output Format + +The tool outputs structured data for workflow consumption: + +``` +--- OUTPUT --- +CHAIN_NAME= +CHAIN_SELECTOR= +ACTION= +``` + +| Action | Description | +|--------|-------------| +| `added` | Chain was successfully added to the proto file | +| `exists` | Chain already exists in the proto file | +| `dry-run` | Dry run mode - no changes made | +| `error` | An error occurred | + +## Finding Chain Selectors + +Chain selectors can be found in the [chain-selectors repository](https://github.com/smartcontractkit/chain-selectors): + +- EVM chains: `selectors.yml` +- Look for the `selector` field for each chain + +Example from `selectors.yml`: +```yaml +selectors: + 1: # Ethereum Mainnet chain ID + selector: 5009297550715157269 + name: "ethereum-mainnet" +``` + +## Running Tests + +```bash +cd cre/go +go test ./tools/add-new-evm-chain/... -v +``` + +## GitHub Workflow + +This tool is also available as a GitHub Actions workflow that can be triggered manually: + +1. Go to **Actions** → **Add Chain Selector** +2. Click **Run workflow** +3. Enter the chain selector value +4. Optionally enable dry-run mode +5. Click **Run workflow** + +The workflow will: +- Run this tool to update the proto file +- Create a branch with the changes +- Open a Pull Request for review + +## Proto File Format + +The tool adds entries to the `defaults` array in `client.proto`: + +```protobuf +defaults: [ + { + key: "avalanche-mainnet" + value: 6433500567565415381 + }, + { + key: "ethereum-mainnet" + value: 5009297550715157269 + } +] +``` + +Entries are automatically sorted alphabetically by chain name. + +## Code Practices & References + +This tool follows Go best practices and idioms from the following resources: + +- [Ardan Labs / Bill Kennedy's Ultimate Go Training](https://www.ardanlabs.com/training/ultimate-go/) +- [Go in Action](https://www.manning.com/books/go-in-action) (Manning Publications) +- [The Go Blog](https://go.dev/blog/) +- [Effective Go](https://go.dev/doc/effective_go) (Official) +- [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) (Official Wiki) +- [The Go Memory Model](https://go.dev/ref/mem) (Official) +- [Go Proverbs](https://go-proverbs.github.io/) (Rob Pike) +- [Google Go Style Guide](https://google.github.io/styleguide/go/) (Style Guide + Best Practices) +- [100 Go Mistakes and How to Avoid Them](https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them) (Teiva Harsanyi) +- [Concurrency in Go](https://www.oreilly.com/library/view/concurrency-in-go/9781491941294/) (Katherine Cox-Buday) diff --git a/cre/go/tools/add-new-evm-chain/main.go b/cre/go/tools/add-new-evm-chain/main.go new file mode 100644 index 00000000..4407252f --- /dev/null +++ b/cre/go/tools/add-new-evm-chain/main.go @@ -0,0 +1,200 @@ +// Package main provides a tool for adding new chain selectors to the chainlink-protos EVM client.proto file. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + chain_selectors "github.com/smartcontractkit/chain-selectors" +) + +// Action represents the outcome action of the tool execution. +type Action string + +// Action constants define the possible outcomes of the tool execution. +const ( + ActionAdded Action = "added" + ActionExists Action = "exists" + ActionDryRun Action = "dry-run" + ActionError Action = "error" +) + +// Config holds the tool configuration from command-line flags. +type Config struct { + ChainSelector uint64 + DryRun bool + ProtoFile string +} + +// Result represents the outcome of the tool execution. +type Result struct { + ChainName string + ChainSelector uint64 + Action Action +} + +func main() { + result, err := run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + // Output structured result for workflow + fmt.Println("\n--- OUTPUT ---") + fmt.Printf("ACTION=%s\n", ActionError) + os.Exit(1) + } + + // Output structured result for workflow consumption + fmt.Println("\n--- OUTPUT ---") + fmt.Printf("CHAIN_NAME=%s\n", result.ChainName) + fmt.Printf("CHAIN_SELECTOR=%d\n", result.ChainSelector) + fmt.Printf("ACTION=%s\n", result.Action) +} + +func run() (*Result, error) { + config, err := parseFlags() + if err != nil { + return nil, err + } + + // Look up chain information from chain-selectors package + chainName, err := lookupChainSelector(config.ChainSelector) + if err != nil { + return nil, fmt.Errorf("failed to look up chain selector: %w", err) + } + + fmt.Printf("Found chain: %s (selector: %d)\n", chainName, config.ChainSelector) + + // Check if chain already exists in proto file + exists, err := chainSelectorExists(config.ProtoFile, chainName) + if err != nil { + return nil, fmt.Errorf("failed to check if chain exists: %w", err) + } + + if exists { + fmt.Printf("Chain %s already exists in proto file, nothing to do\n", chainName) + return &Result{ + ChainName: chainName, + ChainSelector: config.ChainSelector, + Action: ActionExists, + }, nil + } + + if config.DryRun { + fmt.Println("\n=== DRY RUN MODE ===") + fmt.Printf("Would add chain entry:\n") + fmt.Printf(" Key: %s\n", chainName) + fmt.Printf(" Value: %d\n", config.ChainSelector) + fmt.Printf("To file: %s\n", config.ProtoFile) + return &Result{ + ChainName: chainName, + ChainSelector: config.ChainSelector, + Action: ActionDryRun, + }, nil + } + + // Update proto file with new chain + if err := updateProtoFile(config.ProtoFile, chainName, config.ChainSelector); err != nil { + return nil, fmt.Errorf("failed to update proto file: %w", err) + } + + fmt.Printf("Successfully added chain %s to proto file\n", chainName) + + return &Result{ + ChainName: chainName, + ChainSelector: config.ChainSelector, + Action: ActionAdded, + }, nil +} + +func parseFlags() (*Config, error) { + var chainSelector uint64 + var dryRun bool + var protoFile string + + flag.Uint64Var(&chainSelector, "chain-selector", 0, "The chain selector value (required)") + flag.BoolVar(&dryRun, "dry-run", false, "Show what would be done without making changes") + flag.StringVar(&protoFile, "proto-file", "", "Path to client.proto (default: auto-detect)") + + flag.Parse() + + if chainSelector == 0 { + return nil, fmt.Errorf("chain-selector is required") + } + + // Auto-detect proto file path if not provided + if protoFile == "" { + protoFile = findProtoFile() + if protoFile == "" { + return nil, fmt.Errorf("could not auto-detect proto file path, please provide -proto-file flag") + } + } + + // Validate proto file exists + if _, err := os.Stat(protoFile); os.IsNotExist(err) { + return nil, fmt.Errorf("proto file not found: %s", protoFile) + } + + return &Config{ + ChainSelector: chainSelector, + DryRun: dryRun, + ProtoFile: protoFile, + }, nil +} + +// findProtoFile attempts to find the client.proto file relative to the tool location. +func findProtoFile() string { + // Try relative paths from likely locations + candidates := []string{ + "cre/capabilities/blockchain/evm/v1alpha/client.proto", + "../../../capabilities/blockchain/evm/v1alpha/client.proto", + "../../../../cre/capabilities/blockchain/evm/v1alpha/client.proto", + } + + // First try from current working directory + cwd, err := os.Getwd() + if err == nil { + for _, candidate := range candidates { + path := filepath.Join(cwd, candidate) + if _, err := os.Stat(path); err == nil { + return path + } + } + } + + // Try to find it relative to the executable + exe, err := os.Executable() + if err == nil { + exeDir := filepath.Dir(exe) + for _, candidate := range candidates { + path := filepath.Join(exeDir, candidate) + if _, err := os.Stat(path); err == nil { + return path + } + } + } + + return "" +} + +// lookupChainSelector looks up chain information from the chain-selectors package. +func lookupChainSelector(selector uint64) (string, error) { + // Verify it's an EVM chain + family, err := chain_selectors.GetSelectorFamily(selector) + if err != nil { + return "", fmt.Errorf("chain selector %d not found: %w", selector, err) + } + + if family != chain_selectors.FamilyEVM { + return "", fmt.Errorf("chain selector %d is not an EVM chain (family: %s), only EVM chains are supported", selector, family) + } + + // Get chain name + chainName, err := chain_selectors.GetChainNameFromSelector(selector) + if err != nil { + return "", fmt.Errorf("failed to get chain name for selector %d: %w", selector, err) + } + + return chainName, nil +} diff --git a/cre/go/tools/add-new-evm-chain/main_test.go b/cre/go/tools/add-new-evm-chain/main_test.go new file mode 100644 index 00000000..0061ffc1 --- /dev/null +++ b/cre/go/tools/add-new-evm-chain/main_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupChainSelector(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + selector uint64 + wantName string + wantErr bool + errContains string + }{ + { + name: "valid ethereum mainnet selector", + selector: 5009297550715157269, + wantName: "ethereum-mainnet", + wantErr: false, + }, + { + name: "valid avalanche mainnet selector", + selector: 6433500567565415381, + wantName: "avalanche-mainnet", + wantErr: false, + }, + { + name: "valid polygon mainnet selector", + selector: 4051577828743386545, + wantName: "polygon-mainnet", + wantErr: false, + }, + { + name: "invalid selector", + selector: 999999999999, + wantErr: true, + errContains: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + name, err := lookupChainSelector(tt.selector) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, name) + }) + } +} + +func TestParseProtoEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantEntries []ChainEntry + wantErr error + errContains string + }{ + { + name: "valid proto content", + content: ` +defaults: [ + { + key: "ethereum-mainnet" + value: 5009297550715157269 + }, + { + key: "polygon-mainnet" + value: 4051577828743386545 + } + ]`, + wantEntries: []ChainEntry{ + {Key: "ethereum-mainnet", Value: 5009297550715157269}, + {Key: "polygon-mainnet", Value: 4051577828743386545}, + }, + wantErr: nil, + }, + { + name: "missing defaults array", + content: `some other content`, + wantErr: ErrNoDefaultsArray, + }, + { + name: "empty defaults array", + content: ` +defaults: [ + ]`, + wantErr: ErrNoEntriesFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + entries, err := parseProtoEntries(tt.content) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr), "expected error %v, got %v", tt.wantErr, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantEntries, entries) + }) + } +} + +func TestBuildDefaultsArray(t *testing.T) { + t.Parallel() + + entries := []ChainEntry{ + {Key: "avalanche-mainnet", Value: 6433500567565415381}, + {Key: "ethereum-mainnet", Value: 5009297550715157269}, + } + + result := buildDefaultsArray(entries) + + // Check that it contains the expected structure + assert.Contains(t, result, "defaults: [") + assert.Contains(t, result, `key: "avalanche-mainnet"`) + assert.Contains(t, result, `value: 6433500567565415381`) + assert.Contains(t, result, `key: "ethereum-mainnet"`) + assert.Contains(t, result, `value: 5009297550715157269`) + assert.True(t, strings.HasSuffix(result, "]")) +} + +func TestChainSelectorExists(t *testing.T) { + t.Parallel() + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + + content := ` +service Client { + option (tools.generator.v1alpha.capability) = { + labels: { + key: "ChainSelector" + value: { + uint64_label: { + defaults: [ + { + key: "ethereum-mainnet" + value: 5009297550715157269 + } + ] + } + } + } + }; +}` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + tests := []struct { + name string + chainName string + wantExists bool + }{ + { + name: "chain exists", + chainName: "ethereum-mainnet", + wantExists: true, + }, + { + name: "chain does not exist", + chainName: "polygon-mainnet", + wantExists: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + exists, err := chainSelectorExists(testFile, tt.chainName) + require.NoError(t, err) + assert.Equal(t, tt.wantExists, exists) + }) + } +} + +func TestUpdateProtoFile(t *testing.T) { + t.Parallel() + + originalContent := `syntax = "proto3"; + +service Client { + option (tools.generator.v1alpha.capability) = { + labels: { + key: "ChainSelector" + value: { + uint64_label: { + defaults: [ + { + key: "ethereum-mainnet" + value: 5009297550715157269 + } + ] + } + } + } + }; +}` + + t.Run("add new chain", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + + err := os.WriteFile(testFile, []byte(originalContent), 0644) + require.NoError(t, err) + + err = updateProtoFile(testFile, "avalanche-mainnet", 6433500567565415381) + require.NoError(t, err) + + // Read the updated content + updatedContent, err := os.ReadFile(testFile) + require.NoError(t, err) + + // Verify the new chain was added + assert.Contains(t, string(updatedContent), `key: "avalanche-mainnet"`) + assert.Contains(t, string(updatedContent), `value: 6433500567565415381`) + + // Verify original chain still exists + assert.Contains(t, string(updatedContent), `key: "ethereum-mainnet"`) + + // Verify alphabetical ordering (avalanche comes before ethereum) + avaxIdx := strings.Index(string(updatedContent), "avalanche-mainnet") + ethIdx := strings.Index(string(updatedContent), "ethereum-mainnet") + assert.Less(t, avaxIdx, ethIdx, "avalanche should come before ethereum alphabetically") + }) + + t.Run("add chain alphabetically after existing", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + + err := os.WriteFile(testFile, []byte(originalContent), 0644) + require.NoError(t, err) + + err = updateProtoFile(testFile, "polygon-mainnet", 4051577828743386545) + require.NoError(t, err) + + // Read the updated content + updatedContent, err := os.ReadFile(testFile) + require.NoError(t, err) + + // Verify the new chain was added + assert.Contains(t, string(updatedContent), `key: "polygon-mainnet"`) + + // Verify ordering (ethereum comes before polygon) + ethIdx := strings.Index(string(updatedContent), "ethereum-mainnet") + polyIdx := strings.Index(string(updatedContent), "polygon-mainnet") + assert.Less(t, ethIdx, polyIdx, "ethereum should come before polygon alphabetically") + }) + + t.Run("reject duplicate chain", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + + err := os.WriteFile(testFile, []byte(originalContent), 0644) + require.NoError(t, err) + + err = updateProtoFile(testFile, "ethereum-mainnet", 5009297550715157269) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrChainExists), "expected ErrChainExists, got %v", err) + }) +} + +func TestIntegration_DryRun(t *testing.T) { + t.Parallel() + + // This test verifies the dry-run flow works correctly + // by using a real proto file from testdata + + testdataDir := filepath.Join("testdata") + testFile := filepath.Join(testdataDir, "test_client.proto") + + // Skip if testdata file doesn't exist + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("testdata/test_client.proto not found") + } + + // Read original content to verify it's not modified + originalContent, err := os.ReadFile(testFile) + require.NoError(t, err) + + // Verify a chain we know exists + exists, err := chainSelectorExists(testFile, "ethereum-mainnet") + require.NoError(t, err) + assert.True(t, exists, "ethereum-mainnet should exist in test file") + + // Verify a chain that doesn't exist + exists, err = chainSelectorExists(testFile, "some-new-chain") + require.NoError(t, err) + assert.False(t, exists, "some-new-chain should not exist in test file") + + // Verify file wasn't modified + afterContent, err := os.ReadFile(testFile) + require.NoError(t, err) + assert.Equal(t, string(originalContent), string(afterContent), "file should not be modified during read operations") +} diff --git a/cre/go/tools/add-new-evm-chain/proto_updater.go b/cre/go/tools/add-new-evm-chain/proto_updater.go new file mode 100644 index 00000000..073eeafa --- /dev/null +++ b/cre/go/tools/add-new-evm-chain/proto_updater.go @@ -0,0 +1,155 @@ +package main + +import ( + "errors" + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +// Sentinel errors for common error conditions. +var ( + ErrChainExists = errors.New("chain already exists in proto file") + ErrNoDefaultsArray = errors.New("could not find defaults array in proto file") + ErrNoEntriesFound = errors.New("no entries found in defaults array") + ErrPatternNotMatched = errors.New("failed to replace defaults array - pattern may not match") +) + +// Pre-compiled regex patterns for better performance. +// See: 100 Go Mistakes #42 - Not using compiled regular expressions. +var ( + // defaultsArrayRe matches the entire defaults array block. + defaultsArrayRe = regexp.MustCompile(`defaults:\s*\[[\s\S]*?\n\s*\]`) + + // defaultsContentRe extracts the content inside the defaults array. + defaultsContentRe = regexp.MustCompile(`defaults:\s*\[([\s\S]*?)\n\s*\]`) + + // entryRe matches individual chain entries within the defaults array. + entryRe = regexp.MustCompile(`\{\s*key:\s*"([^"]+)"\s*value:\s*(\d+)\s*\}`) +) + +// ChainEntry represents a key-value entry in the proto file. +type ChainEntry struct { + Key string + Value uint64 +} + +// chainSelectorExists checks if a chain selector already exists in the proto file. +func chainSelectorExists(protoFile, chainName string) (bool, error) { + fileContent, err := os.ReadFile(protoFile) + if err != nil { + return false, fmt.Errorf("failed to read proto file: %w", err) + } + + // Build pattern to check if the chain name already exists + pattern := regexp.MustCompile(fmt.Sprintf(`key:\s*"%s"`, regexp.QuoteMeta(chainName))) + return pattern.MatchString(string(fileContent)), nil +} + +// updateProtoFile updates the proto file with a new chain selector entry. +func updateProtoFile(protoFile, chainName string, selector uint64) error { + fileContent, err := os.ReadFile(protoFile) + if err != nil { + return fmt.Errorf("failed to read proto file: %w", err) + } + + protoContent := string(fileContent) + + // Parse existing entries + entries, err := parseProtoEntries(protoContent) + if err != nil { + return fmt.Errorf("failed to parse proto entries: %w", err) + } + + // Check if entry already exists + for _, entry := range entries { + if entry.Key == chainName { + return fmt.Errorf("%w: %s", ErrChainExists, chainName) + } + } + + // Add new entry + entries = append(entries, ChainEntry{ + Key: chainName, + Value: selector, + }) + + // Sort entries alphabetically by key + sort.Slice(entries, func(i, j int) bool { + return entries[i].Key < entries[j].Key + }) + + // Rebuild the defaults array + newDefaults := buildDefaultsArray(entries) + + // Replace the defaults array in the content + newContent := defaultsArrayRe.ReplaceAllString(protoContent, newDefaults) + if newContent == protoContent { + return ErrPatternNotMatched + } + + // Write back to file + if err := os.WriteFile(protoFile, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write proto file: %w", err) + } + + return nil +} + +// parseProtoEntries parses the defaults array from the proto file. +func parseProtoEntries(protoContent string) ([]ChainEntry, error) { + // Find the defaults array content + matches := defaultsContentRe.FindStringSubmatch(protoContent) + if len(matches) < 2 { + return nil, ErrNoDefaultsArray + } + + defaultsContent := matches[1] + + // Parse each entry + entryMatches := entryRe.FindAllStringSubmatch(defaultsContent, -1) + if len(entryMatches) == 0 { + return nil, ErrNoEntriesFound + } + + entries := make([]ChainEntry, 0, len(entryMatches)) + for _, match := range entryMatches { + if len(match) < 3 { + continue + } + + key := match[1] + value, err := strconv.ParseUint(match[2], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse selector value %s for key %s: %w", match[2], key, err) + } + + entries = append(entries, ChainEntry{ + Key: key, + Value: value, + }) + } + + return entries, nil +} + +// buildDefaultsArray builds the defaults array string from entries. +func buildDefaultsArray(entries []ChainEntry) string { + var builder strings.Builder + builder.WriteString("defaults: [\n") + + for i, entry := range entries { + builder.WriteString(fmt.Sprintf(" {\n key: \"%s\"\n value: %d\n }", entry.Key, entry.Value)) + if i < len(entries)-1 { + builder.WriteString(",\n") + } else { + builder.WriteString("\n") + } + } + + builder.WriteString(" ]") + return builder.String() +} diff --git a/cre/go/tools/add-new-evm-chain/testdata/test_client.proto b/cre/go/tools/add-new-evm-chain/testdata/test_client.proto new file mode 100644 index 00000000..9adfe93b --- /dev/null +++ b/cre/go/tools/add-new-evm-chain/testdata/test_client.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +package capabilities.blockchain.evm.v1alpha; + +service Client { + option (tools.generator.v1alpha.capability) = { + mode: MODE_DON + capability_id: "evm@1.0.0" + labels: { + key: "ChainSelector" + value: { + uint64_label: { + defaults: [ + { + key: "avalanche-mainnet" + value: 6433500567565415381 + }, + { + key: "ethereum-mainnet" + value: 5009297550715157269 + }, + { + key: "polygon-mainnet" + value: 4051577828743386545 + } + ] + } + } + } + }; +}