Skip to content
Open
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
3 changes: 1 addition & 2 deletions deployment/cre/jobs/operations/propose_std_cap_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"github.com/smartcontractkit/chainlink/deployment"
"github.com/smartcontractkit/chainlink/deployment/common/view"
"github.com/smartcontractkit/chainlink/deployment/cre/jobs/pkg"
job_types "github.com/smartcontractkit/chainlink/deployment/cre/jobs/types"
"github.com/smartcontractkit/chainlink/deployment/cre/pkg/offchain"
"github.com/smartcontractkit/chainlink/deployment/helpers/pointer"
)
Expand Down Expand Up @@ -266,7 +265,7 @@ func lookupEVMJobByName(ctx context.Context, lggr logger.Logger, jobName, nodeID
continue
}

ji := make(job_types.JobSpecInput)
ji := make(map[string]any)
if err = toml.Unmarshal([]byte(j.Spec), &ji); err != nil {
return "", false, fmt.Errorf("failed to unmarshal job spec toml for job %s on node %s: %w", jobName, nodeID, err)
}
Expand Down
32 changes: 16 additions & 16 deletions deployment/cre/jobs/pkg/std_cap.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,27 @@ const (
)

type StandardCapabilityJob struct {
JobName string // Must be alphanumeric, with _, -, ., no spaces.
Command string `yaml:"command"`
Config string `yaml:"config"`
JobName string `json:"jobName" yaml:"jobName"` // Must be alphanumeric, with _, -, ., no spaces.
Command string `json:"command" yaml:"command"`
Config string `json:"config" yaml:"config"`

// If not provided, ExternalJobID is automatically filled in by calling `externalJobIDHashFunc`
ExternalJobID string `yaml:"externalJobID"`
ExternalJobID string `json:"externalJobID" yaml:"externalJobID"`
// OracleFactory is the configuration for the Oracle Factory job.
OracleFactory *OracleFactory `yaml:"oracleFactory"`
OracleFactory *OracleFactory `json:"oracleFactory" yaml:"oracleFactory"`

// Additional fields used to drive oracle factory creation/config
GenerateOracleFactory bool // if true, an oracle factory will be generated using the fields below
OCRSigningStrategy string `yaml:"ocrSigningStrategy"` // used to set the signing strategy in the oracle factory
ContractQualifier string `yaml:"contractQualifier"` // qualifier for the OCR3 contract or CapabilitiesRegistry (when capRegVersion is set)
OCRChainSelector ChainSelector `yaml:"ocrChainSelector"` // contract chain selector, doesn't have to live on the same chain as the evm selector
UseCapRegOCRConfig bool `yaml:"useCapRegOCRConfig"` // if true, use CapabilitiesRegistry instead of legacy OCR3 contract for oracle factory config
CapRegVersion string `yaml:"capRegVersion"` // CapabilitiesRegistry contract version (e.g. "2.0.0"); required when useCapRegOCRConfig is true

ChainSelectorEVM ChainSelector `yaml:"chainSelectorEVM"` // used to fetch OCR EVM configs from nodes
ChainSelectorAptos ChainSelector `yaml:"chainSelectorAptos"` // used to fetch OCR Aptos configs from nodes - optional
ChainSelectorSolana ChainSelector `yaml:"chainSelectorSolana"` // used to fetch OCR Solana configs from nodes - optional
BootstrapPeers []string `yaml:"bootstrapPeers"` // set as value in the oracle factory
GenerateOracleFactory bool `json:"generateOracleFactory" yaml:"generateOracleFactory"` // if true, an oracle factory will be generated using the fields below
OCRSigningStrategy string `json:"ocrSigningStrategy" yaml:"ocrSigningStrategy"` // used to set the signing strategy in the oracle factory
ContractQualifier string `json:"contractQualifier" yaml:"contractQualifier"` // qualifier for the OCR3 contract or CapabilitiesRegistry (when capRegVersion is set)
OCRChainSelector ChainSelector `json:"ocrChainSelector" yaml:"ocrChainSelector"` // contract chain selector, doesn't have to live on the same chain as the evm selector
UseCapRegOCRConfig bool `json:"useCapRegOCRConfig" yaml:"useCapRegOCRConfig"` // if true, use CapabilitiesRegistry instead of legacy OCR3 contract for oracle factory config
CapRegVersion string `json:"capRegVersion" yaml:"capRegVersion"` // CapabilitiesRegistry contract version (e.g. "2.0.0"); required when useCapRegOCRConfig is true

ChainSelectorEVM ChainSelector `json:"chainSelectorEVM" yaml:"chainSelectorEVM"` // used to fetch OCR EVM configs from nodes
ChainSelectorAptos ChainSelector `json:"chainSelectorAptos" yaml:"chainSelectorAptos"` // used to fetch OCR Aptos configs from nodes - optional
ChainSelectorSolana ChainSelector `json:"chainSelectorSolana" yaml:"chainSelectorSolana"` // used to fetch OCR Solana configs from nodes - optional
BootstrapPeers []string `json:"bootstrapPeers" yaml:"bootstrapPeers"` // set as value in the oracle factory
}

func (s *StandardCapabilityJob) Resolve() (string, error) {
Expand Down
18 changes: 9 additions & 9 deletions deployment/cre/jobs/pkg/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ import (
)

type OracleFactory struct {
Enabled bool `yaml:"enabled"`
BootstrapPeers []string `yaml:"bootstrapPeers"`
OCRContractAddress string `yaml:"ocrContractAddress"`
OCRKeyBundleID string `yaml:"ocrKeyBundleID"`
ChainID string `yaml:"chainID"`
TransmitterID string `yaml:"transmitterID"`
OnchainSigningStrategy OnchainSigningStrategy `yaml:"onchainSigningStrategy"`
Enabled bool `json:"enabled" yaml:"enabled"`
BootstrapPeers []string `json:"bootstrapPeers" yaml:"bootstrapPeers"`
OCRContractAddress string `json:"ocrContractAddress" yaml:"ocrContractAddress"`
OCRKeyBundleID string `json:"ocrKeyBundleID" yaml:"ocrKeyBundleID"`
ChainID string `json:"chainID" yaml:"chainID"`
TransmitterID string `json:"transmitterID" yaml:"transmitterID"`
OnchainSigningStrategy OnchainSigningStrategy `json:"onchainSigningStrategy" yaml:"onchainSigningStrategy"`
}

type OnchainSigningStrategy struct {
StrategyName string `yaml:"strategyName"`
Config map[string]string `yaml:"config"`
StrategyName string `json:"strategyName" yaml:"strategyName"`
Config map[string]string `json:"config" yaml:"config"`
}

type OracleFactoryConfig struct {
Expand Down
10 changes: 5 additions & 5 deletions deployment/cre/jobs/propose_job_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type ProposeJobSpecInput struct {
// Inputs is a map of input variables to be used in the job spec template.
// These will vary based on the template used, and will be validated differently
// for each template type.
Inputs job_types.JobSpecInput `json:"inputs" yaml:"inputs"`
Inputs *job_types.JobSpecInput `json:"inputs" yaml:"inputs"`
}

type ProposeJobSpec struct{}
Expand Down Expand Up @@ -59,20 +59,20 @@ func (u ProposeJobSpec) VerifyPreconditions(_ cldf.Environment, config ProposeJo

switch config.Template {
case job_types.EVM:
if err := verifyEVMJobSpecInputs(config.Inputs); err != nil {
if err := verifyEVMJobSpecInputs(*config.Inputs); err != nil {
return fmt.Errorf("invalid inputs for EVM job spec: %w", err)
}
case job_types.Solana:
if err := verifySolanaJobSpecInputs(config.Inputs); err != nil {
if err := verifySolanaJobSpecInputs(*config.Inputs); err != nil {
return fmt.Errorf("invalid inputs for EVM job spec: %w", err)
Comment thread
cedric-cordenier marked this conversation as resolved.
Comment on lines 60 to 67
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VerifyPreconditions dereferences config.Inputs (e.g., *config.Inputs) before checking whether Inputs is nil. If inputs are omitted in the request/YAML, this will panic instead of returning a validation error. Move the config.Inputs == nil check to before the switch (and before any dereference), or avoid dereferencing until after the nil check.

Copilot uses AI. Check for mistakes.
}
case job_types.Cron, job_types.BootstrapOCR3, job_types.OCR3, job_types.Gateway, job_types.HTTPTrigger, job_types.HTTPAction, job_types.ConfidentialHTTP, job_types.BootstrapVault, job_types.Consensus, job_types.WebAPITrigger, job_types.WebAPITarget, job_types.CustomCompute, job_types.LogEventTrigger, job_types.ReadContract:
case job_types.CRESettings:
if err := verifyCRESettingsSpecInputs(config.Inputs); err != nil {
if err := verifyCRESettingsSpecInputs(*config.Inputs); err != nil {
return fmt.Errorf("invalid inputs for CRE settings job spec: %w", err)
}
case job_types.Ring:
if err := verifyRingJobSpecInputs(config.Inputs); err != nil {
if err := verifyRingJobSpecInputs(*config.Inputs); err != nil {
return fmt.Errorf("invalid inputs for Ring job spec: %w", err)
}
default:
Expand Down
30 changes: 18 additions & 12 deletions deployment/cre/jobs/types/job_spec.go
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
package job_types

import (
"encoding/json"
"errors"
"fmt"
"strings"

"gopkg.in/yaml.v3"

"github.com/smartcontractkit/chainlink/deployment/cre/jobs/pkg"
)

type JobSpecInput map[string]any
type JobSpecInput struct {
json.RawMessage
}
Comment on lines +12 to +14
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.

Suggested change
type JobSpecInput struct {
json.RawMessage
}
type JobSpecInput json.RawMessag

Would this work too?


func (j JobSpecInput) UnmarshalTo(target any) error {
bytes, err := yaml.Marshal(j)
if err != nil {
return fmt.Errorf("failed to marshal job spec input to json: %w", err)
}
func (j JobSpecInput) MarshalJSON() ([]byte, error) {
return j.RawMessage.MarshalJSON()
}

func (j *JobSpecInput) UnmarshalJSON(d []byte) error {
j.RawMessage = d
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnmarshalJSON assigns the input byte slice directly to RawMessage. Per the encoding/json contract, UnmarshalJSON must copy the data if it needs to retain it after returning (the decoder may reuse the backing array). Make a copy of d before storing it to avoid subtle data corruption.

Suggested change
j.RawMessage = d
if d == nil {
j.RawMessage = nil
return nil
}
copied := make([]byte, len(d))
copy(copied, d)
j.RawMessage = json.RawMessage(copied)

Copilot uses AI. Check for mistakes.
return nil
}

return yaml.Unmarshal(bytes, target)
func (j JobSpecInput) UnmarshalTo(target any) error {
return json.Unmarshal([]byte(j.RawMessage), target)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnmarshalTo calls json.Unmarshal on j.RawMessage. When JobSpecInput is the zero value (RawMessage is nil/empty), json.Unmarshal returns "unexpected end of JSON input". If callers previously relied on an empty inputs object (or default/omitted inputs) being treated as an empty map, consider treating empty RawMessage as a no-op (or as {}) to preserve backwards-compatible behavior.

Suggested change
return json.Unmarshal([]byte(j.RawMessage), target)
// Treat empty or nil RawMessage as a no-op to preserve backward-compatible behavior
// where omitted/empty inputs are interpreted as default/zero-valued targets.
if len(j.RawMessage) == 0 {
return nil
}
// Also treat explicit JSON null as a no-op for consistency with default semantics.
if string(j.RawMessage) == "null" {
return nil
}
return json.Unmarshal(j.RawMessage, target)

Copilot uses AI. Check for mistakes.
}
Comment thread
cedric-cordenier marked this conversation as resolved.

func (j JobSpecInput) UnmarshalFrom(source any) error {
bytes, err := yaml.Marshal(source)
func (j *JobSpecInput) UnmarshalFrom(source any) error {
bytes, err := json.Marshal(source)
if err != nil {
return fmt.Errorf("failed to marshal source to json: %w", err)
}

return yaml.Unmarshal(bytes, &j)
j.RawMessage = bytes
return nil
}

func (j JobSpecInput) ToStandardCapabilityJob(jobName string, generateOracleFactory bool) (pkg.StandardCapabilityJob, error) {
Expand Down
84 changes: 27 additions & 57 deletions deployment/cre/jobs/types/job_spec_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package job_types_test

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -10,29 +11,36 @@ import (
job_types "github.com/smartcontractkit/chainlink/deployment/cre/jobs/types"
)

func mustMarshal(t *testing.T, v any) job_types.JobSpecInput {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return job_types.JobSpecInput{RawMessage: b}
}

func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) {
t.Parallel()

jobName := "test-job"

t.Run("successful conversion", func(t *testing.T) {
input := job_types.JobSpecInput{
input := mustMarshal(t, map[string]any{
"command": "run",
"config": "param=value",
"externalJobID": "123",
"oracleFactory": pkg.OracleFactory{
Enabled: true,
BootstrapPeers: []string{"peer1", "peer2"},
OCRContractAddress: "0x123",
OCRKeyBundleID: "bundle-id",
ChainID: "chain-id",
TransmitterID: "transmitter-id",
OnchainSigningStrategy: pkg.OnchainSigningStrategy{
StrategyName: "strategy-name",
Config: map[string]string{"key": "value"},
"oracleFactory": map[string]any{
"enabled": true,
"bootstrapPeers": []string{"peer1", "peer2"},
"ocrContractAddress": "0x123",
"ocrKeyBundleID": "bundle-id",
"chainID": "chain-id",
"transmitterID": "transmitter-id",
"onchainSigningStrategy": map[string]any{
"strategyName": "strategy-name",
"config": map[string]string{"key": "value"},
},
},
}
})

job, err := input.ToStandardCapabilityJob(jobName, false)
require.NoError(t, err)
Expand All @@ -51,72 +59,34 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) {
})

t.Run("missing command", func(t *testing.T) {
input := job_types.JobSpecInput{
input := mustMarshal(t, map[string]any{
"config": "param=value",
"externalJobID": "123",
"oracleFactory": pkg.OracleFactory{},
}
})
_, err := input.ToStandardCapabilityJob(jobName, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "command is required")
})

t.Run("invalid command type", func(t *testing.T) {
input := job_types.JobSpecInput{
"command": nil,
t.Run("missing command field entirely", func(t *testing.T) {
input := mustMarshal(t, map[string]any{
"config": "param=value",
"externalJobID": "123",
"oracleFactory": pkg.OracleFactory{},
}
})
_, err := input.ToStandardCapabilityJob(jobName, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "command is required and must be a string")
})

t.Run("config is optional", func(t *testing.T) {
input := job_types.JobSpecInput{
input := mustMarshal(t, map[string]any{
"command": "run",
"config": "",
"externalJobID": "123",
"oracleFactory": pkg.OracleFactory{},
}
})
_, err := input.ToStandardCapabilityJob(jobName, false)
require.NoError(t, err)
})

t.Run("invalid config type", func(t *testing.T) {
input := job_types.JobSpecInput{
"command": "run",
"config": struct{}{},
"externalJobID": "123",
"oracleFactory": pkg.OracleFactory{},
}
_, err := input.ToStandardCapabilityJob(jobName, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot unmarshal !!map into string")
})

t.Run("invalid externalJobID type", func(t *testing.T) {
input := job_types.JobSpecInput{
"command": "run",
"config": "param=value",
"externalJobID": struct{}{},
"oracleFactory": pkg.OracleFactory{},
}
_, err := input.ToStandardCapabilityJob(jobName, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot unmarshal !!map into string")
})

t.Run("invalid oracleFactory type", func(t *testing.T) {
input := job_types.JobSpecInput{
"command": "run",
"config": "param=value",
"externalJobID": "123",
"oracleFactory": "not a factory",
}
_, err := input.ToStandardCapabilityJob(jobName, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot unmarshal !!str")
})
}
Loading