diff --git a/.github/workflows/devenv-compat.yml b/.github/workflows/devenv-compat.yml
index 5e37f46f665..5999ea2ba8e 100644
--- a/.github/workflows/devenv-compat.yml
+++ b/.github/workflows/devenv-compat.yml
@@ -17,7 +17,9 @@ on:
type: boolean
default: false
refs:
- description: "Git refs to test, should be used only for tool testing purposes. In all other cases refs are detected automatically either from Git or RANE SOT"
+ description: "Git refs to test, should be used only for tool testing purposes.
+ In all other cases refs are detected automatically either from Git or
+ RANE SOT"
required: false
type: string
exclude-refs:
@@ -56,6 +58,11 @@ on:
required: false
default: "2"
type: string
+ tag-version-ceiling:
+ description: "Version ceiling for compatibility testing (tags newer than this
+ version will be excluded)"
+ required: false
+ type: string
working-directory:
description: "Working directory for commands"
required: false
@@ -95,13 +102,16 @@ jobs:
DON_NODES: ${{ inputs.don-nodes }}
NODE_NAME_TEMPLATE: ${{ inputs.node-name-template }}
VERSIONS_BACK: ${{ inputs.versions-back }}
+ TAG_VERSION_CEILING: ${{ inputs.tag-version-ceiling }}
WORKING_DIR: ${{ inputs.working-directory }}
LOGS_DIR: ${{ inputs.logs-directory }}
CTF_LOG_LEVEL: ${{ inputs.ctf-log-level }}
# JD is required by the local CRE-based tests
- CTF_JD_IMAGE: "${{ secrets.AWS_ACCOUNT_ID_PROD }}.dkr.ecr.${{ secrets.QA_AWS_REGION }}.amazonaws.com/job-distributor:0.22.1"
+ CTF_JD_IMAGE: "${{ secrets.AWS_ACCOUNT_ID_PROD }}.dkr.ecr.${{
+ secrets.QA_AWS_REGION }}.amazonaws.com/job-distributor:0.22.1"
# ChIP Router is required by the local CRE-based tests
- CTF_CHIP_ROUTER_IMAGE: "${{ secrets.QA_AWS_ACCOUNT_NUMBER }}.dkr.ecr.${{ secrets.QA_AWS_REGION }}.amazonaws.com/local-cre-chip-router:v1.0.1"
+ CTF_CHIP_ROUTER_IMAGE: "${{ secrets.QA_AWS_ACCOUNT_NUMBER }}.dkr.ecr.${{
+ secrets.QA_AWS_REGION }}.amazonaws.com/local-cre-chip-router:v1.0.1"
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -129,7 +139,8 @@ jobs:
id: login-ecr
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
with:
- registries: ${{ format('{0},{1}', secrets.QA_AWS_ACCOUNT_NUMBER, secrets.AWS_ACCOUNT_ID_PROD) }}
+ registries: ${{ format('{0},{1}', secrets.QA_AWS_ACCOUNT_NUMBER,
+ secrets.AWS_ACCOUNT_ID_PROD) }}
env:
AWS_REGION: ${{ secrets.QA_AWS_REGION }}
@@ -157,7 +168,7 @@ jobs:
working-directory: ${{ env.WORKING_DIR }}
run: |
CTF_VERSION=$(go list -m -json "github.com/smartcontractkit/chainlink-testing-framework/framework" | jq -r .Version)
- echo "CTF_TAG=framework/$CTF_VERSION" >> $GITHUB_ENV
+ echo "CTF_TAG=framework/$CTF_VERSION" >> "$GITHUB_ENV"
- name: Install CTF binary
working-directory: ${{ env.WORKING_DIR }}
@@ -199,19 +210,26 @@ jobs:
STRIP_IMAGE_SUFFIX: ${{ env.STRIP_IMAGE_SUFFIX }}
PRODUCT: ${{ env.PRODUCT }}
NO_GIT_ROLLBACK: ${{ env.NO_GIT_ROLLBACK }}
+ REFS: ${{ env.REFS }}
+ EXCLUDE_REFS: ${{ env.EXCLUDE_REFS }}
BUILD_CMD: ${{ env.BUILD_CMD }}
ENV_CMD: ${{ env.ENV_CMD }}
TEST_CMD: ${{ env.TEST_CMD }}
NODES: ${{ env.NODES }}
VERSIONS_BACK: ${{ env.VERSIONS_BACK }}
+ TAG_VERSION_CEILING: ${{ env.TAG_VERSION_CEILING }}
run: |
- REFS_ARGS=""
- if [ -n "${{ env.REFS }}" ]; then
- REFS_ARGS="--refs ${{ env.REFS }}"
+ REFS_ARGS=()
+ if [ -n "${REFS}" ]; then
+ REFS_ARGS=(--refs "${REFS}")
+ fi
+ EXCLUDE_REFS_ARGS=()
+ if [ -n "${EXCLUDE_REFS}" ]; then
+ EXCLUDE_REFS_ARGS=(--exclude-refs "${EXCLUDE_REFS}")
fi
- EXCLUDE_REFS_ARGS=""
- if [ -n "${{ env.EXCLUDE_REFS }}" ]; then
- EXCLUDE_REFS_ARGS="--exclude-refs ${{ env.EXCLUDE_REFS }}"
+ ROLLBACK_FLAG=()
+ if [ -n "${NO_GIT_ROLLBACK}" ]; then
+ ROLLBACK_FLAG=("${NO_GIT_ROLLBACK}")
fi
./bin/ctf compat backward \
--registry "${REGISTRY}" \
@@ -219,26 +237,29 @@ jobs:
--envcmd "${ENV_CMD}" \
--testcmd "${TEST_CMD}" \
--product "${PRODUCT}" \
- ${REFS_ARGS} \
- ${EXCLUDE_REFS_ARGS} \
+ "${REFS_ARGS[@]}" \
+ "${EXCLUDE_REFS_ARGS[@]}" \
--upgrade-nodes "${UPGRADE_NODES}" \
--don_nodes "${DON_NODES}" \
--node-name-template "${NODE_NAME_TEMPLATE}" \
--strip-image-suffix "${STRIP_IMAGE_SUFFIX}" \
--versions-back "${VERSIONS_BACK}" \
- ${{ env.NO_GIT_ROLLBACK }}
+ --tag-version-ceiling "${TAG_VERSION_CEILING}" \
+ "${ROLLBACK_FLAG[@]}"
- name: Attach CI Summary
working-directory: ${{ env.WORKING_DIR }}
if: always()
run: |
if [ -f "ci_summary.txt" ]; then
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "**CI Summary:**" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- cat ci_summary.txt >> $GITHUB_STEP_SUMMARY
+ {
+ echo ""
+ echo "**CI Summary:**"
+ echo '```'
+ cat ci_summary.txt
+ } >> "$GITHUB_STEP_SUMMARY"
else
- echo "ci_summary.txt not found" >> $GITHUB_STEP_SUMMARY
+ echo "ci_summary.txt not found" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Collect Docker container logs
@@ -246,10 +267,11 @@ jobs:
working-directory: ${{ env.WORKING_DIR }}
run: |
mkdir -p logs
- for container in $(docker ps -q); do
- container_name=$(docker inspect --format='{{.Name}}' $container | sed 's/^\///')
- docker logs $container > "logs/${container_name}.log" 2>&1
- done
+ while IFS= read -r container; do
+ [ -z "${container}" ] && continue
+ container_name=$(docker inspect --format='{{.Name}}' "${container}" | sed 's/^\///')
+ docker logs "${container}" > "logs/${container_name}.log" 2>&1
+ done < <(docker ps -q)
- name: Upload logs
if: always()
diff --git a/.github/workflows/post-build-publish.yml b/.github/workflows/post-build-publish.yml
index af58b6af19b..3ebae6a007b 100644
--- a/.github/workflows/post-build-publish.yml
+++ b/.github/workflows/post-build-publish.yml
@@ -27,12 +27,17 @@ jobs:
echo "Chainlink Version is required, but 'client_payload.chainlink_version' is empty"
exit 1
fi
+ if [[ "$CHAINLINK_VERSION" != v* ]]; then
+ echo "This workflow can only be used with semver tags, 'chainlink_version' must begin with 'v', got: $CHAINLINK_VERSION"
+ exit 1
+ fi
echo "Trigger validated successfully"
# Upgrade compatibility tests
# Include rc and beta releases, since we will be upgrading from them on canary servers
df1-compat:
uses: ./.github/workflows/devenv-compat.yml
name: Data Feeds v1 Upgrade Test
+ needs: validate-trigger
permissions:
id-token: write
contents: read
@@ -48,11 +53,13 @@ jobs:
logs-directory: "./devenv/tests/ocr2/logs"
ctf-log-level: "debug"
strip-image-suffix: "v"
+ tag-version-ceiling: ${{ github.event.client_payload.chainlink_version }}
secrets: inherit
cre-compat:
uses: ./.github/workflows/devenv-compat.yml
name: CRE Upgrade Test
+ needs: validate-trigger
permissions:
id-token: write
contents: read
@@ -68,6 +75,7 @@ jobs:
logs-directory: "./system-tests/tests/smoke/cre/logs"
ctf-log-level: "debug"
strip-image-suffix: "v"
+ tag-version-ceiling: ${{ github.event.client_payload.chainlink_version }}
secrets: inherit
legacy-system-tests:
diff --git a/.github/workflows/post-image-released.yml b/.github/workflows/post-image-released.yml
index 081558ce334..866cc76974c 100644
--- a/.github/workflows/post-image-released.yml
+++ b/.github/workflows/post-image-released.yml
@@ -2,6 +2,12 @@ name: "Post Image Released"
on:
# to be used with post release tests
workflow_dispatch:
+ inputs:
+ tag-version-ceiling:
+ description: "Version ceiling for compatibility testing (tags newer than this
+ version will be excluded)"
+ required: false
+ type: string
permissions: {}
@@ -26,6 +32,7 @@ jobs:
logs-directory: "./devenv/tests/ocr2/logs"
ctf-log-level: "debug"
strip-image-suffix: "v"
+ tag-version-ceiling: ${{ inputs.tag-version-ceiling }}
secrets: inherit
cre-compat:
@@ -46,5 +53,6 @@ jobs:
logs-directory: "./system-tests/tests/smoke/cre/logs"
ctf-log-level: "debug"
strip-image-suffix: "v"
+ tag-version-ceiling: ${{ inputs.tag-version-ceiling }}
secrets: inherit
# Do not run other tests, since they were already run on the pre-release image
diff --git a/core/config/app_config.go b/core/config/app_config.go
index 37755cbb265..68f2f9954a0 100644
--- a/core/config/app_config.go
+++ b/core/config/app_config.go
@@ -63,6 +63,7 @@ type AppConfig interface {
CCV() CCV
Billing() Billing
BridgeStatusReporter() BridgeStatusReporter
+ JobSpecReporter() JobSpecReporter
Sharding() Sharding
LOOPP() LOOPP
}
diff --git a/core/config/docs/core.toml b/core/config/docs/core.toml
index 62d666b08d2..078b56a0899 100644
--- a/core/config/docs/core.toml
+++ b/core/config/docs/core.toml
@@ -913,6 +913,16 @@ IgnoreInvalidBridges = true # Default
# IgnoreJoblessBridges skips bridges that have no associated jobs.
IgnoreJoblessBridges = false # Default
+# JobSpecReporter holds settings for the Job Spec Reporter service, which periodically emits job spec telemetry.
+[JobSpecReporter]
+# Enabled enables the Job Spec Reporter service.
+Enabled = false # Default
+# PollingInterval is how often to emit a heartbeat event for each tracked job.
+PollingInterval = "1h" # Default
+# EnabledOCR2PluginTypes restricts OCR2 telemetry to jobs with these plugin types.
+# An empty list disables all OCR2 telemetry. Use ["all"] to enable all OCR2 plugin types.
+EnabledOCR2PluginTypes = ["median"] # Default
+
[CRE]
# UseLocalTimeProvider should be set true if the DON Time OCR Plugin is not running
UseLocalTimeProvider = true # Default
diff --git a/core/config/job_spec_reporter_config.go b/core/config/job_spec_reporter_config.go
new file mode 100644
index 00000000000..2f5c0e8c6f8
--- /dev/null
+++ b/core/config/job_spec_reporter_config.go
@@ -0,0 +1,11 @@
+package config
+
+import "time"
+
+type JobSpecReporter interface {
+ Enabled() bool
+ PollingInterval() time.Duration
+ // EnabledOCR2PluginTypes is the allowlist of OCR2 plugin types to emit for
+ // (e.g. "median"). An empty slice disables all. Use ["all"] to enable all types.
+ EnabledOCR2PluginTypes() []string
+}
diff --git a/core/config/toml/types.go b/core/config/toml/types.go
index cb0ec6a9ff6..26be8d50ef4 100644
--- a/core/config/toml/types.go
+++ b/core/config/toml/types.go
@@ -9,6 +9,7 @@ import (
"reflect"
"regexp"
"strings"
+ "time"
"github.com/google/uuid"
"go.uber.org/zap/zapcore"
@@ -64,6 +65,7 @@ type Core struct {
CRE CreConfig `toml:",omitempty"`
Billing Billing `toml:",omitempty"`
BridgeStatusReporter BridgeStatusReporter `toml:",omitempty"`
+ JobSpecReporter JobSpecReporter `toml:",omitempty"`
Sharding Sharding `toml:",omitempty"`
LOOPP LOOPP `toml:",omitempty"`
}
@@ -110,6 +112,7 @@ func (c *Core) SetFrom(f *Core) {
c.CRE.setFrom(&f.CRE)
c.Billing.setFrom(&f.Billing)
c.BridgeStatusReporter.setFrom(&f.BridgeStatusReporter)
+ c.JobSpecReporter.setFrom(&f.JobSpecReporter)
c.Sharding.setFrom(&f.Sharding)
c.LOOPP.setFrom(&f.LOOPP)
@@ -3040,6 +3043,46 @@ func (e *BridgeStatusReporter) ValidateConfig() error {
return nil
}
+type JobSpecReporter struct {
+ Enabled *bool
+ PollingInterval *commonconfig.Duration
+ EnabledOCR2PluginTypes *[]string
+}
+
+func (e *JobSpecReporter) setFrom(f *JobSpecReporter) {
+ if f.Enabled != nil {
+ e.Enabled = f.Enabled
+ }
+ if f.PollingInterval != nil {
+ e.PollingInterval = f.PollingInterval
+ }
+ if f.EnabledOCR2PluginTypes != nil {
+ e.EnabledOCR2PluginTypes = f.EnabledOCR2PluginTypes
+ }
+}
+
+func (e *JobSpecReporter) ValidateConfig() error {
+ if e.Enabled == nil || !*e.Enabled {
+ return nil
+ }
+
+ if e.PollingInterval == nil {
+ defaultInterval := commonconfig.MustNewDuration(time.Hour)
+ e.PollingInterval = defaultInterval
+ }
+
+ if e.PollingInterval.Duration() < config.MinimumPollingInterval {
+ return configutils.ErrInvalid{Name: "PollingInterval", Value: e.PollingInterval.Duration(), Msg: "must be greater than or equal to: " + config.MinimumPollingInterval.String()}
+ }
+
+ if e.EnabledOCR2PluginTypes == nil {
+ defaultTypes := []string{"median"}
+ e.EnabledOCR2PluginTypes = &defaultTypes
+ }
+
+ return nil
+}
+
type JobDistributor struct {
DisplayName *string
}
diff --git a/core/scripts/go.mod b/core/scripts/go.mod
index 8f6bcfd8992..f46c857ea31 100644
--- a/core/scripts/go.mod
+++ b/core/scripts/go.mod
@@ -53,7 +53,7 @@ require (
github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0
- github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19
+ github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21
github.com/smartcontractkit/chainlink-testing-framework/framework/components/dockercompose v0.1.22
github.com/smartcontractkit/chainlink-testing-framework/lib v1.54.5
github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5
@@ -486,7 +486,7 @@ require (
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20260129135848-c86808ba5cb9 // indirect
github.com/smartcontractkit/ccip-owner-contracts v0.1.0 // indirect
- github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d // indirect
+ github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb // indirect
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 // indirect
github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260408145530-22e2d05695cd // indirect
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc // indirect
@@ -498,13 +498,14 @@ require (
github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 // indirect
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c // indirect
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c // indirect
- github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c // indirect
+ github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83 // indirect
github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 // indirect
diff --git a/core/scripts/go.sum b/core/scripts/go.sum
index e4ab15e127b..51cb8e4d05f 100644
--- a/core/scripts/go.sum
+++ b/core/scripts/go.sum
@@ -1635,8 +1635,8 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L
github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY=
github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww=
github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d h1:KMZmEzlZ+iknVFCuv+8o9iEKK52HXyeauIWbo+KhYv8=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb h1:6UjHnVanvb+6yJuefhyVlfv6YKFGMeZY5jv+a7Sexyo=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb/go.mod h1:FEm5fvIQe5O8Qdx6GvQcXsk7rDFpmYdIWXea5i4tpjw=
github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU=
github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08=
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8=
@@ -1679,8 +1679,8 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202604231355
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HcwehCao5k5C2NGuKJUVoX/AYtoH6njGFiV44dBOcY4=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c h1:0c+bCKo47vy/ItRtGa3S/vCpE5LRlgXpGnVKQX8TgjE=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c h1:1VVreRcffo3N3zF1JVtgS+YC4puuj3y0FU0Fta7L3U0=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 h1:v/rAtObo9zMpQDnGQ0seaSEqw60JUjowwcNw3DCpVY4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 h1:nXU0s4WAVU2cAR76Ke7h9z55NuEtRq1WvT4wVEs7jwk=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ=
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE=
@@ -1695,6 +1695,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
@@ -1723,8 +1725,8 @@ github.com/smartcontractkit/chainlink-sui v0.0.0-20260429183453-39df0198aed8 h1:
github.com/smartcontractkit/chainlink-sui v0.0.0-20260429183453-39df0198aed8/go.mod h1:k1HSbHyPaQWPOj6lXDIAe04EuwbC5ge1nK+cpG2E8hE=
github.com/smartcontractkit/chainlink-sui/deployment v0.0.0-20260427132612-76b9f754a556 h1:6ocsoNPu3T0LsBiZ1tGZrjhKu8pGC1opUFz5KgHALSU=
github.com/smartcontractkit/chainlink-sui/deployment v0.0.0-20260427132612-76b9f754a556/go.mod h1:LkUo0a46JWaCsLY4SCV5ZOESudehe2RR62C1S46iOqw=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 h1:inTH0/PrEaVv4iLdGsdcrP/rX7KMrq/Roosr5nIA8io=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21 h1:lQs+r0+Jz9vFhmWPSo4bh0F1AePGIN7j++ex9cI4jA4=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3 h1:57wap/rDhBcw6+Ld7MqwQmXt2BTyVeNGvvPFDJcO8jQ=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3/go.mod h1:MjUJAyU+kLvLLPRPBs3X7zYadds5umZcjTLHjfNhpUc=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/dockercompose v0.1.22 h1:V+3clQPZ0/N8PJhJDkl00giFtsf9lv5XQJl0SCGWzcM=
diff --git a/core/services/chainlink/application.go b/core/services/chainlink/application.go
index 83922a84f5b..d2fae426464 100644
--- a/core/services/chainlink/application.go
+++ b/core/services/chainlink/application.go
@@ -7,6 +7,7 @@ import (
"fmt"
"math/big"
"net/http"
+ "os"
"strconv"
"sync"
"time"
@@ -70,6 +71,7 @@ import (
"github.com/smartcontractkit/chainlink/v2/core/services/keystore"
"github.com/smartcontractkit/chainlink/v2/core/services/llo/retirement"
"github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/bridgestatus"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec"
"github.com/smartcontractkit/chainlink/v2/core/services/ocr"
"github.com/smartcontractkit/chainlink/v2/core/services/ocr2"
"github.com/smartcontractkit/chainlink/v2/core/services/ocrbootstrap"
@@ -790,8 +792,9 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err
srvcs = append(srvcs, jobSpawner, pipelineRunner)
var feedsService feeds.Service
+ var feedsORM feeds.ORM
if cfg.Feature().FeedsManager() {
- feedsORM := feeds.NewORM(opts.DS, globalLogger)
+ feedsORM = feeds.NewORM(opts.DS, globalLogger)
feedsService = feeds.NewService(
feedsORM,
jobORM,
@@ -814,6 +817,19 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err
feedsService = &feeds.NullService{}
}
+ hostname, _ := os.Hostname()
+ jobSpecReporter := jobspec.NewJobSpecReporter(
+ cfg.JobSpecReporter(),
+ jobSpawner,
+ feedsORM,
+ beholder.GetEmitter(),
+ csaPubKeyHex,
+ static.Version,
+ hostname,
+ globalLogger,
+ )
+ srvcs = append(srvcs, jobSpecReporter)
+
for _, s := range srvcs {
if s == nil {
panic("service unexpectedly nil")
diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go
index 3eb77367607..9a4f4fe6a23 100644
--- a/core/services/chainlink/config_general.go
+++ b/core/services/chainlink/config_general.go
@@ -591,6 +591,10 @@ func (g *generalConfig) BridgeStatusReporter() coreconfig.BridgeStatusReporter {
return &bridgeStatusReporterConfig{c: g.c.BridgeStatusReporter}
}
+func (g *generalConfig) JobSpecReporter() coreconfig.JobSpecReporter {
+ return &jobSpecReporterConfig{c: g.c.JobSpecReporter}
+}
+
func (g *generalConfig) Sharding() coreconfig.Sharding {
return &shardingConfig{s: g.c.Sharding}
}
diff --git a/core/services/chainlink/config_job_spec_reporter.go b/core/services/chainlink/config_job_spec_reporter.go
new file mode 100644
index 00000000000..687d6e735bb
--- /dev/null
+++ b/core/services/chainlink/config_job_spec_reporter.go
@@ -0,0 +1,35 @@
+package chainlink
+
+import (
+ "time"
+
+ "github.com/smartcontractkit/chainlink/v2/core/config"
+ "github.com/smartcontractkit/chainlink/v2/core/config/toml"
+)
+
+var _ config.JobSpecReporter = (*jobSpecReporterConfig)(nil)
+
+type jobSpecReporterConfig struct {
+ c toml.JobSpecReporter
+}
+
+func (e *jobSpecReporterConfig) Enabled() bool {
+ if e.c.Enabled == nil {
+ return false
+ }
+ return *e.c.Enabled
+}
+
+func (e *jobSpecReporterConfig) PollingInterval() time.Duration {
+ if e.c.PollingInterval == nil {
+ return time.Hour
+ }
+ return e.c.PollingInterval.Duration()
+}
+
+func (e *jobSpecReporterConfig) EnabledOCR2PluginTypes() []string {
+ if e.c.EnabledOCR2PluginTypes == nil {
+ return []string{"median"}
+ }
+ return *e.c.EnabledOCR2PluginTypes
+}
diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go
index 911c88d8e1e..df3f0953097 100644
--- a/core/services/chainlink/config_test.go
+++ b/core/services/chainlink/config_test.go
@@ -595,6 +595,12 @@ func TestConfig_Marshal(t *testing.T) {
IgnoreInvalidBridges: ptr(true),
IgnoreJoblessBridges: ptr(false),
}
+ enabledOCR2PluginTypes := []string{"median"}
+ full.JobSpecReporter = toml.JobSpecReporter{
+ Enabled: ptr(true),
+ PollingInterval: commoncfg.MustNewDuration(time.Hour),
+ EnabledOCR2PluginTypes: &enabledOCR2PluginTypes,
+ }
full.Sharding = toml.Sharding{
ShardingEnabled: ptr(false),
ArbiterPort: ptr[uint16](9876),
diff --git a/core/services/chainlink/mocks/general_config.go b/core/services/chainlink/mocks/general_config.go
index a7fbb69d415..e1f781a3924 100644
--- a/core/services/chainlink/mocks/general_config.go
+++ b/core/services/chainlink/mocks/general_config.go
@@ -1295,6 +1295,53 @@ func (_c *GeneralConfig_JobPipeline_Call) RunAndReturn(run func() config.JobPipe
return _c
}
+// JobSpecReporter provides a mock function with no fields
+func (_m *GeneralConfig) JobSpecReporter() config.JobSpecReporter {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for JobSpecReporter")
+ }
+
+ var r0 config.JobSpecReporter
+ if rf, ok := ret.Get(0).(func() config.JobSpecReporter); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(config.JobSpecReporter)
+ }
+ }
+
+ return r0
+}
+
+// GeneralConfig_JobSpecReporter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobSpecReporter'
+type GeneralConfig_JobSpecReporter_Call struct {
+ *mock.Call
+}
+
+// JobSpecReporter is a helper method to define mock.On call
+func (_e *GeneralConfig_Expecter) JobSpecReporter() *GeneralConfig_JobSpecReporter_Call {
+ return &GeneralConfig_JobSpecReporter_Call{Call: _e.mock.On("JobSpecReporter")}
+}
+
+func (_c *GeneralConfig_JobSpecReporter_Call) Run(run func()) *GeneralConfig_JobSpecReporter_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *GeneralConfig_JobSpecReporter_Call) Return(_a0 config.JobSpecReporter) *GeneralConfig_JobSpecReporter_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *GeneralConfig_JobSpecReporter_Call) RunAndReturn(run func() config.JobSpecReporter) *GeneralConfig_JobSpecReporter_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// LOOPP provides a mock function with no fields
func (_m *GeneralConfig) LOOPP() config.LOOPP {
ret := _m.Called()
diff --git a/core/services/chainlink/testdata/config-empty-effective.toml b/core/services/chainlink/testdata/config-empty-effective.toml
index a073a30ed45..84fef8a54a1 100644
--- a/core/services/chainlink/testdata/config-empty-effective.toml
+++ b/core/services/chainlink/testdata/config-empty-effective.toml
@@ -384,6 +384,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/services/chainlink/testdata/config-full.toml b/core/services/chainlink/testdata/config-full.toml
index 7583f0ec595..953565dc714 100644
--- a/core/services/chainlink/testdata/config-full.toml
+++ b/core/services/chainlink/testdata/config-full.toml
@@ -422,6 +422,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = true
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/services/chainlink/testdata/config-multi-chain-effective.toml b/core/services/chainlink/testdata/config-multi-chain-effective.toml
index ad20742feed..3f055aa2c40 100644
--- a/core/services/chainlink/testdata/config-multi-chain-effective.toml
+++ b/core/services/chainlink/testdata/config-multi-chain-effective.toml
@@ -384,6 +384,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/services/feeds/mocks/orm.go b/core/services/feeds/mocks/orm.go
index 6ce30a0a78c..7b527b0727c 100644
--- a/core/services/feeds/mocks/orm.go
+++ b/core/services/feeds/mocks/orm.go
@@ -1034,6 +1034,65 @@ func (_c *ORM_GetJobProposal_Call) RunAndReturn(run func(context.Context, int64)
return _c
}
+// GetJobProposalByExternalJobID provides a mock function with given fields: ctx, externalJobID
+func (_m *ORM) GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (*feeds.JobProposal, error) {
+ ret := _m.Called(ctx, externalJobID)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetJobProposalByExternalJobID")
+ }
+
+ var r0 *feeds.JobProposal
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (*feeds.JobProposal, error)); ok {
+ return rf(ctx, externalJobID)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) *feeds.JobProposal); ok {
+ r0 = rf(ctx, externalJobID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*feeds.JobProposal)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
+ r1 = rf(ctx, externalJobID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// ORM_GetJobProposalByExternalJobID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobProposalByExternalJobID'
+type ORM_GetJobProposalByExternalJobID_Call struct {
+ *mock.Call
+}
+
+// GetJobProposalByExternalJobID is a helper method to define mock.On call
+// - ctx context.Context
+// - externalJobID uuid.UUID
+func (_e *ORM_Expecter) GetJobProposalByExternalJobID(ctx interface{}, externalJobID interface{}) *ORM_GetJobProposalByExternalJobID_Call {
+ return &ORM_GetJobProposalByExternalJobID_Call{Call: _e.mock.On("GetJobProposalByExternalJobID", ctx, externalJobID)}
+}
+
+func (_c *ORM_GetJobProposalByExternalJobID_Call) Run(run func(ctx context.Context, externalJobID uuid.UUID)) *ORM_GetJobProposalByExternalJobID_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(uuid.UUID))
+ })
+ return _c
+}
+
+func (_c *ORM_GetJobProposalByExternalJobID_Call) Return(_a0 *feeds.JobProposal, _a1 error) *ORM_GetJobProposalByExternalJobID_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *ORM_GetJobProposalByExternalJobID_Call) RunAndReturn(run func(context.Context, uuid.UUID) (*feeds.JobProposal, error)) *ORM_GetJobProposalByExternalJobID_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// GetJobProposalByRemoteUUID provides a mock function with given fields: ctx, _a1
func (_m *ORM) GetJobProposalByRemoteUUID(ctx context.Context, _a1 uuid.UUID) (*feeds.JobProposal, error) {
ret := _m.Called(ctx, _a1)
diff --git a/core/services/feeds/orm.go b/core/services/feeds/orm.go
index c0edd00a3a3..a6b6015667f 100644
--- a/core/services/feeds/orm.go
+++ b/core/services/feeds/orm.go
@@ -40,6 +40,7 @@ type ORM interface {
DeleteProposal(ctx context.Context, id int64) error
GetJobProposal(ctx context.Context, id int64) (*JobProposal, error)
GetJobProposalByRemoteUUID(ctx context.Context, uuid uuid.UUID) (*JobProposal, error)
+ GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (*JobProposal, error)
ListJobProposalsByManagersIDs(ctx context.Context, ids []int64) ([]JobProposal, error)
UpdateJobProposalStatus(ctx context.Context, id int64, status JobProposalStatus) error // NEEDED?
UpsertJobProposal(ctx context.Context, jp *JobProposal) (int64, error)
@@ -432,6 +433,21 @@ AND status <> $2;
return jp, errors.Wrap(err, "GetJobProposalByRemoteUUID failed")
}
+// GetJobProposalByExternalJobID gets a non-deleted job proposal by its external job ID.
+func (o *orm) GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (jp *JobProposal, err error) {
+ stmt := `
+SELECT *
+FROM job_proposals
+WHERE external_job_id = $1
+AND status <> $2
+LIMIT 1;
+`
+
+ jp = new(JobProposal)
+ err = o.ds.GetContext(ctx, jp, stmt, externalJobID, JobProposalStatusDeleted)
+ return jp, errors.Wrap(err, "GetJobProposalByExternalJobID failed")
+}
+
// ListJobProposalsByManagersIDs gets job proposals by feeds managers IDs.
func (o *orm) ListJobProposalsByManagersIDs(ctx context.Context, ids []int64) ([]JobProposal, error) {
stmt := `
diff --git a/core/services/feeds/orm_test.go b/core/services/feeds/orm_test.go
index bb2009399a6..b131c98001c 100644
--- a/core/services/feeds/orm_test.go
+++ b/core/services/feeds/orm_test.go
@@ -610,6 +610,28 @@ func Test_ORM_GetJobProposal(t *testing.T) {
_, err = orm.GetJobProposalByRemoteUUID(ctx, uuid.New())
require.Error(t, err)
})
+
+ t.Run("by external job id", func(t *testing.T) {
+ externalJobID := uuid.New()
+ createJob(t, orm.db, externalJobID)
+ jpID := createJobProposal(t, orm, feeds.JobProposalStatusPending, fmID)
+ specID := createJobSpec(t, orm, jpID)
+
+ err := orm.ApproveSpec(ctx, specID, externalJobID)
+ require.NoError(t, err)
+
+ actual, err := orm.GetJobProposalByExternalJobID(ctx, externalJobID)
+ require.NoError(t, err)
+
+ assert.Equal(t, jpID, actual.ID)
+ assert.Equal(t, uuid.NullUUID{UUID: externalJobID, Valid: true}, actual.ExternalJobID)
+ assert.Equal(t, feeds.JobProposalStatusApproved, actual.Status)
+
+ require.NoError(t, orm.DeleteProposal(ctx, jpID))
+
+ _, err = orm.GetJobProposalByExternalJobID(ctx, externalJobID)
+ require.Error(t, err)
+ })
}
func Test_ORM_CountJobProposalsByStatus(t *testing.T) {
diff --git a/core/services/job/mocks/spawner.go b/core/services/job/mocks/spawner.go
index 28ff46461b1..22b6013d4be 100644
--- a/core/services/job/mocks/spawner.go
+++ b/core/services/job/mocks/spawner.go
@@ -348,6 +348,39 @@ func (_c *Spawner_Ready_Call) RunAndReturn(run func() error) *Spawner_Ready_Call
return _c
}
+// RegisterListener provides a mock function with given fields: l
+func (_m *Spawner) RegisterListener(l job.Listener) {
+ _m.Called(l)
+}
+
+// Spawner_RegisterListener_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterListener'
+type Spawner_RegisterListener_Call struct {
+ *mock.Call
+}
+
+// RegisterListener is a helper method to define mock.On call
+// - l job.Listener
+func (_e *Spawner_Expecter) RegisterListener(l interface{}) *Spawner_RegisterListener_Call {
+ return &Spawner_RegisterListener_Call{Call: _e.mock.On("RegisterListener", l)}
+}
+
+func (_c *Spawner_RegisterListener_Call) Run(run func(l job.Listener)) *Spawner_RegisterListener_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(job.Listener))
+ })
+ return _c
+}
+
+func (_c *Spawner_RegisterListener_Call) Return() *Spawner_RegisterListener_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *Spawner_RegisterListener_Call) RunAndReturn(run func(job.Listener)) *Spawner_RegisterListener_Call {
+ _c.Run(run)
+ return _c
+}
+
// Start provides a mock function with given fields: _a0
func (_m *Spawner) Start(_a0 context.Context) error {
ret := _m.Called(_a0)
diff --git a/core/services/job/spawner.go b/core/services/job/spawner.go
index e883fb23b47..c0b1fdf5968 100644
--- a/core/services/job/spawner.go
+++ b/core/services/job/spawner.go
@@ -17,6 +17,13 @@ import (
)
type (
+ // Listener is notified when the Spawner starts or stops a job.
+ // Callbacks run asynchronously and must not block.
+ Listener interface {
+ AfterJobStarted(ctx context.Context, jb Job)
+ AfterJobStopped(ctx context.Context, jb Job)
+ }
+
// Spawner manages the spinning up and down of the long-running
// services that perform the work described by job specs. Each active job spec
// has 1 or more of these services associated with it.
@@ -35,6 +42,10 @@ type (
// NOTE: Prefer to use CreateJob, this is only publicly exposed for use in tests
// to start a job that was previously manually inserted into DB
StartService(ctx context.Context, spec Job) error
+
+ // RegisterListener adds l to the set of listeners notified on job start/stop.
+ // Safe to call before or after Start.
+ RegisterListener(l Listener)
}
Checker interface {
@@ -52,6 +63,9 @@ type (
activeJobsMu sync.RWMutex
lggr logger.Logger
+ listeners []Listener
+ listenersMu sync.RWMutex
+
chStop services.StopChan
lbDependentAwaiters []utils.DependentAwaiter
}
@@ -274,6 +288,7 @@ func (js *spawner) CreateJob(ctx context.Context, ds sqlutil.DataSource, jb *Job
js.lggr.Errorw("Error starting job services", "type", jb.Type, "jobID", jb.ID, "err", err)
} else {
js.lggr.Infow("Started job services", "type", jb.Type, "jobID", jb.ID)
+ js.notifyStarted(*jb)
}
delegate.AfterJobCreated(*jb)
@@ -340,6 +355,7 @@ func (js *spawner) DeleteJob(ctx context.Context, ds sqlutil.DataSource, jobID i
if exists {
// Stop the service and remove the job from memory, which will always happen even if closing the services fail.
js.stopService(jobID)
+ js.notifyStopped(aj.spec)
}
lggr.Infow("Stopped and deleted job")
@@ -357,6 +373,48 @@ func (js *spawner) ActiveJobs() map[int32]Job {
return m
}
+func (js *spawner) RegisterListener(l Listener) {
+ js.listenersMu.Lock()
+ defer js.listenersMu.Unlock()
+ js.listeners = append(js.listeners, l)
+ js.lggr.Debugf("Registered job listener %T", l)
+}
+
+func (js *spawner) notifyStarted(jb Job) {
+ js.dispatchToListeners(func(ctx context.Context, l Listener) { l.AfterJobStarted(ctx, jb) })
+}
+
+func (js *spawner) notifyStopped(jb Job) {
+ js.dispatchToListeners(func(ctx context.Context, l Listener) { l.AfterJobStopped(ctx, jb) })
+}
+
+// dispatchToListeners fans out fn to every registered listener in a single
+// best-effort goroutine. Panics are recovered so a faulty listener cannot
+// bring the spawner down.
+func (js *spawner) dispatchToListeners(fn func(context.Context, Listener)) {
+ js.listenersMu.RLock()
+ ls := make([]Listener, len(js.listeners))
+ copy(ls, js.listeners)
+ js.listenersMu.RUnlock()
+
+ if len(ls) == 0 {
+ return
+ }
+
+ ctx, cancel := js.chStop.NewCtx()
+ go func() {
+ defer cancel()
+ defer func() {
+ if r := recover(); r != nil {
+ js.lggr.Errorw("Panic in job spawner listener", "recover", r)
+ }
+ }()
+ for _, l := range ls {
+ fn(ctx, l)
+ }
+ }()
+}
+
func (js *spawner) activeJobIDs() []int32 {
js.activeJobsMu.RLock()
defer js.activeJobsMu.RUnlock()
diff --git a/core/services/nodestatusreporter/jobspec/events/emit.go b/core/services/nodestatusreporter/jobspec/events/emit.go
new file mode 100644
index 00000000000..cb97783c0a2
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/emit.go
@@ -0,0 +1,34 @@
+package events
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "google.golang.org/protobuf/proto"
+
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+)
+
+func EmitJobSpecEvent(ctx context.Context, emitter beholder.Emitter, event *JobSpecEvent) error {
+ if event.Timestamp == "" {
+ event.Timestamp = time.Now().Format(time.RFC3339Nano)
+ }
+
+ eventBytes, err := proto.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("failed to marshal JobSpecEvent: %w", err)
+ }
+
+ err = emitter.Emit(ctx, eventBytes,
+ "partitionkey", event.ExternalJobId,
+ "beholder_data_schema", SchemaJobSpec,
+ "beholder_domain", BeholderDomain,
+ "beholder_entity", fmt.Sprintf("%s.%s", ProtoPkg, JobSpecEventEntity),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to emit JobSpecEvent: %w", err)
+ }
+
+ return nil
+}
diff --git a/core/services/nodestatusreporter/jobspec/events/emit_test.go b/core/services/nodestatusreporter/jobspec/events/emit_test.go
new file mode 100644
index 00000000000..236619620c2
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/emit_test.go
@@ -0,0 +1,65 @@
+package events_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder/beholdertest"
+
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events"
+)
+
+func TestEmitJobSpecEvent_RoundTrip(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+ emitter := beholder.GetEmitter()
+
+ event := &events.JobSpecEvent{
+ ExternalJobId: "test-job-id",
+ Name: "test-job",
+ JobType: "offchainreporting2",
+ EmissionTrigger: events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT,
+ }
+
+ err := events.EmitJobSpecEvent(context.Background(), emitter, event)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ msg := msgs[0]
+ require.Equal(t, "test-job-id", msg.Attrs["partitionkey"])
+ require.Equal(t, events.SchemaJobSpec, msg.Attrs["beholder_data_schema"])
+ require.Equal(t, events.BeholderDomain, msg.Attrs["beholder_domain"])
+ require.NotContains(t, msg.Attrs, "source")
+
+ var decoded events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msg.Body, &decoded))
+ require.Equal(t, "test-job-id", decoded.ExternalJobId)
+ require.Equal(t, "test-job", decoded.Name)
+ require.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT, decoded.EmissionTrigger)
+ require.NotEmpty(t, decoded.Timestamp)
+}
+
+func TestEmitJobSpecEvent_SetsTimestampIfEmpty(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+ emitter := beholder.GetEmitter()
+
+ event := &events.JobSpecEvent{
+ ExternalJobId: "ts-test",
+ }
+ require.Empty(t, event.Timestamp)
+
+ err := events.EmitJobSpecEvent(context.Background(), emitter, event)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var decoded events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &decoded))
+ require.NotEmpty(t, decoded.Timestamp)
+}
diff --git a/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go b/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go
new file mode 100644
index 00000000000..2f200c555f8
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go
@@ -0,0 +1,26 @@
+// Code generated by chainlink-protos import shim. DO NOT EDIT.
+// source: job_spec.proto
+
+package events
+
+import job_specv1 "github.com/smartcontractkit/chainlink-protos/data-feeds/job_spec/v1"
+
+type EmissionTrigger = job_specv1.EmissionTrigger
+
+const (
+ EmissionTrigger_EMISSION_TRIGGER_UNSPECIFIED = job_specv1.EmissionTrigger_EMISSION_TRIGGER_UNSPECIFIED
+ EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT = job_specv1.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT
+ EmissionTrigger_EMISSION_TRIGGER_CREATE = job_specv1.EmissionTrigger_EMISSION_TRIGGER_CREATE
+ EmissionTrigger_EMISSION_TRIGGER_DELETE = job_specv1.EmissionTrigger_EMISSION_TRIGGER_DELETE
+)
+
+var (
+ EmissionTrigger_name = job_specv1.EmissionTrigger_name
+ EmissionTrigger_value = job_specv1.EmissionTrigger_value
+ File_job_spec_proto = job_specv1.File_job_spec_v1_job_spec_event_proto
+)
+
+type JobSpecEvent = job_specv1.JobSpecEvent
+type OCR2EVMRelayConfig = job_specv1.OCR2EVMRelayConfig
+type OCR2MedianPluginConfig = job_specv1.OCR2MedianPluginConfig
+type OCR2OracleSpecInfo = job_specv1.OCR2OracleSpecInfo
diff --git a/core/services/nodestatusreporter/jobspec/events/types.go b/core/services/nodestatusreporter/jobspec/events/types.go
new file mode 100644
index 00000000000..4718d893c46
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/types.go
@@ -0,0 +1,8 @@
+package events
+
+const (
+ ProtoPkg = "job_spec.v1"
+ JobSpecEventEntity = "JobSpecEvent"
+ SchemaJobSpec = "/job-spec-events/v1"
+ BeholderDomain = "data-feeds.job-spec"
+)
diff --git a/core/services/nodestatusreporter/jobspec/job_spec_reporter.go b/core/services/nodestatusreporter/jobspec/job_spec_reporter.go
new file mode 100644
index 00000000000..79ec4ffb53c
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/job_spec_reporter.go
@@ -0,0 +1,395 @@
+package jobspec
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "time"
+
+ "github.com/google/uuid"
+ "google.golang.org/protobuf/proto"
+
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+ "github.com/smartcontractkit/chainlink-common/pkg/services"
+ commontypes "github.com/smartcontractkit/chainlink-common/pkg/types"
+
+ coreconfig "github.com/smartcontractkit/chainlink/v2/core/config"
+ "github.com/smartcontractkit/chainlink/v2/core/logger"
+ "github.com/smartcontractkit/chainlink/v2/core/services/feeds"
+ "github.com/smartcontractkit/chainlink/v2/core/services/job"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events"
+ "github.com/smartcontractkit/chainlink/v2/core/services/pipeline"
+)
+
+const ServiceName = "JobSpecReporter"
+
+var _ job.Listener = (*Service)(nil)
+
+// Service polls active jobs and pushes their specs to Beholder, and also emits
+// on job create/delete via the job.Listener interface.
+type Service struct {
+ services.Service
+ eng *services.Engine
+
+ config coreconfig.JobSpecReporter
+ spawner job.Spawner
+ feedsORM feeds.ORM
+ emitter beholder.Emitter
+ csaPublicKey string
+ nodeVersion string
+ hostname string
+}
+
+func NewJobSpecReporter(
+ config coreconfig.JobSpecReporter,
+ spawner job.Spawner,
+ feedsORM feeds.ORM,
+ emitter beholder.Emitter,
+ csaPublicKey string,
+ nodeVersion string,
+ hostname string,
+ lggr logger.Logger,
+) *Service {
+ s := &Service{
+ config: config,
+ spawner: spawner,
+ feedsORM: feedsORM,
+ emitter: emitter,
+ csaPublicKey: csaPublicKey,
+ nodeVersion: nodeVersion,
+ hostname: hostname,
+ }
+ s.Service, s.eng = services.Config{
+ Name: ServiceName,
+ Start: s.start,
+ }.NewServiceEngine(lggr)
+ return s
+}
+
+func (s *Service) start(ctx context.Context) error {
+ if !s.config.Enabled() {
+ s.eng.Info("Job Spec Reporter Service is disabled")
+ return nil
+ }
+
+ s.eng.Info("Starting Job Spec Reporter Service")
+ s.spawner.RegisterListener(s)
+ ticker := services.NewTicker(s.config.PollingInterval())
+ s.eng.GoTick(ticker, s.pollAllJobs)
+
+ return nil
+}
+
+func (s *Service) HealthReport() map[string]error {
+ return map[string]error{ServiceName: s.Ready()}
+}
+
+// AfterJobStarted emits a create event when a job starts.
+func (s *Service) AfterJobStarted(ctx context.Context, jb job.Job) {
+ if !s.ShouldEmit(&jb) {
+ return
+ }
+ if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_CREATE); err != nil {
+ s.eng.Warnw("Failed to emit job spec telemetry on create", "jobID", jb.ID, "error", err)
+ }
+}
+
+// AfterJobStopped emits a delete event when a job is removed.
+func (s *Service) AfterJobStopped(ctx context.Context, jb job.Job) {
+ if !s.ShouldEmit(&jb) {
+ return
+ }
+ if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_DELETE); err != nil {
+ s.eng.Warnw("Failed to emit job spec telemetry on delete", "jobID", jb.ID, "error", err)
+ }
+}
+
+// pollAllJobs emits heartbeat telemetry for every active job that passes the emit gate.
+func (s *Service) pollAllJobs(ctx context.Context) {
+ for _, jb := range s.spawner.ActiveJobs() {
+ if !s.ShouldEmit(&jb) {
+ continue
+ }
+ if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT); err != nil {
+ s.eng.Warnw("Failed to emit job spec telemetry", "jobID", jb.ID, "error", err)
+ }
+ }
+}
+
+// ShouldEmit reports whether the job passes the config-driven emit gate.
+func (s *Service) ShouldEmit(j *job.Job) bool {
+ if j == nil {
+ return false
+ }
+ if j.Type != job.OffchainReporting2 || j.OCR2OracleSpec == nil {
+ return false
+ }
+ allowed := s.config.EnabledOCR2PluginTypes()
+ if len(allowed) == 0 {
+ return false
+ }
+ if slices.Contains(allowed, "all") {
+ return true
+ }
+ return slices.Contains(allowed, string(j.OCR2OracleSpec.PluginType))
+}
+
+// EmitForJob builds and emits a JobSpecEvent for the given job and trigger.
+func (s *Service) EmitForJob(ctx context.Context, jb job.Job, trigger events.EmissionTrigger) error {
+ if jb.Type != job.OffchainReporting2 || jb.OCR2OracleSpec == nil {
+ return fmt.Errorf("unsupported job type %s", jb.Type)
+ }
+
+ event, err := s.buildEvent(ctx, jb, trigger)
+ if err != nil {
+ return fmt.Errorf("building event: %w", err)
+ }
+
+ if err := events.EmitJobSpecEvent(ctx, s.emitter, event); err != nil {
+ return fmt.Errorf("emitting event: %w", err)
+ }
+ return nil
+}
+
+// buildEvent converts a job.Job into its protobuf JobSpecEvent representation.
+func (s *Service) buildEvent(ctx context.Context, jb job.Job, trigger events.EmissionTrigger) (*events.JobSpecEvent, error) {
+ event := &events.JobSpecEvent{
+ ExternalJobId: jb.ExternalJobID.String(),
+ Name: jb.Name.ValueOrZero(),
+ JobType: string(jb.Type),
+ SchemaVersion: jb.SchemaVersion,
+ ForwardingAllowed: jb.ForwardingAllowed,
+ CreatedAt: jb.CreatedAt.Format(time.RFC3339Nano),
+ CsaPublicKey: s.csaPublicKey,
+ NodeVersion: s.nodeVersion,
+ Hostname: s.hostname,
+ EmissionTrigger: trigger,
+ Timestamp: time.Now().Format(time.RFC3339Nano),
+ }
+
+ if jb.GasLimit.Valid {
+ event.GasLimit = proto.Uint32(jb.GasLimit.Uint32)
+ }
+ if jb.StreamID != nil {
+ sid := *jb.StreamID
+ event.StreamId = proto.Uint32(sid)
+ }
+
+ if jb.PipelineSpec != nil {
+ event.ObservationSource = jb.PipelineSpec.DotDagSource
+ bridgeNames, err := extractBridgeNames(jb)
+ if err != nil {
+ return nil, fmt.Errorf("extracting bridge names: %w", err)
+ }
+ event.BridgeNames = bridgeNames
+ }
+
+ if err := s.populateProposalLifecycle(ctx, jb, event); err != nil {
+ s.eng.Warnw("Failed to populate proposal lifecycle", "jobID", jb.ID, "error", err)
+ }
+
+ ocr2Info, err := buildOCR2OracleSpecInfo(jb.OCR2OracleSpec)
+ if err != nil {
+ return nil, fmt.Errorf("building OCR2OracleSpecInfo: %w", err)
+ }
+ event.Ocr2OracleSpec = ocr2Info
+
+ return event, nil
+}
+
+// populateProposalLifecycle fills in proposal/approval fields for jobs created
+// via the Feeds Manager. Jobs not managed by Feeds Manager are a no-op.
+func (s *Service) populateProposalLifecycle(ctx context.Context, jb job.Job, event *events.JobSpecEvent) error {
+ if s.feedsORM == nil || jb.ExternalJobID == uuid.Nil {
+ return nil
+ }
+
+ prop, err := s.feedsORM.GetJobProposalByExternalJobID(ctx, jb.ExternalJobID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ return fmt.Errorf("fetching job proposal: %w", err)
+ }
+
+ spec, err := s.feedsORM.GetApprovedSpec(ctx, prop.ID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ return fmt.Errorf("fetching approved spec: %w", err)
+ }
+
+ event.FeedsManagerId = prop.FeedsManagerID
+ event.RemoteUuid = prop.RemoteUUID.String()
+ event.SpecVersion = spec.Version
+ event.ProposedAt = spec.CreatedAt.Format(time.RFC3339Nano)
+ event.ApprovedAt = spec.StatusUpdatedAt.Format(time.RFC3339Nano)
+ event.AcceptLatencySeconds = spec.StatusUpdatedAt.Sub(spec.CreatedAt).Seconds()
+ return nil
+}
+
+// extractBridgeNames returns the names of bridge tasks in the top-level pipeline.
+// Tasks inside sub-pipelines (e.g. juelsPerFeeCoinSource) are not included.
+func extractBridgeNames(jb job.Job) ([]string, error) {
+ names := extractBridgeNamesFromPipeline(jb.Pipeline)
+ if len(names) > 0 || jb.PipelineSpec == nil || jb.PipelineSpec.DotDagSource == "" {
+ return names, nil
+ }
+
+ p, err := pipeline.Parse(jb.PipelineSpec.DotDagSource)
+ if err != nil {
+ return nil, err
+ }
+ return extractBridgeNamesFromPipeline(*p), nil
+}
+
+func extractBridgeNamesFromPipeline(p pipeline.Pipeline) []string {
+ var names []string
+ for _, task := range p.Tasks {
+ if task.Type() != pipeline.TaskTypeBridge {
+ continue
+ }
+ bt, ok := task.(*pipeline.BridgeTask)
+ if !ok {
+ continue
+ }
+ names = append(names, bt.Name)
+ }
+ return names
+}
+
+func ocr2ChainID(spec *job.OCR2OracleSpec) string {
+ if spec == nil {
+ return ""
+ }
+ if spec.ChainID != "" {
+ return spec.ChainID
+ }
+ relayID, err := spec.RelayID()
+ if err != nil {
+ return ""
+ }
+ return relayID.ChainID
+}
+
+// evmRelayConfig mirrors the EVM relay config JSON so we can surface its fields
+// in OCR2EVMRelayConfig without depending on the EVM module.
+type evmRelayConfig struct {
+ FromBlock *uint64 `json:"fromBlock"`
+ EffectiveTransmitterID *string `json:"effectiveTransmitterID"`
+ EnableDualTransmission *bool `json:"enableDualTransmission"`
+ EnableTriggerCapability *bool `json:"enableTriggerCapability"`
+ LLODonID *uint64 `json:"lloDonID"`
+ FeedID *string `json:"feedID"`
+ SendingKeys []string `json:"sendingKeys"`
+ ProviderType *string `json:"providerType"`
+}
+
+type medianPluginConfig struct {
+ JuelsPerFeeCoinPipeline *string `json:"juelsPerFeeCoinSource"`
+}
+
+// buildOCR2OracleSpecInfo converts an OCR2OracleSpec into the proto message.
+func buildOCR2OracleSpecInfo(spec *job.OCR2OracleSpec) (*events.OCR2OracleSpecInfo, error) {
+ relayConfigRaw, err := json.Marshal(spec.RelayConfig)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling relay config: %w", err)
+ }
+ pluginConfigRaw, err := json.Marshal(spec.PluginConfig)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling plugin config: %w", err)
+ }
+ info := &events.OCR2OracleSpecInfo{
+ ContractId: spec.ContractID,
+ Relay: spec.Relay,
+ PluginType: string(spec.PluginType),
+ CaptureEaTelemetry: spec.CaptureEATelemetry,
+ RelayConfigJson: string(relayConfigRaw),
+ PluginConfigJson: string(pluginConfigRaw),
+ }
+
+ if spec.FeedID != nil {
+ info.FeedId = proto.String(spec.FeedID.Hex())
+ }
+ if spec.TransmitterID.Valid {
+ info.TransmitterId = proto.String(spec.TransmitterID.String)
+ }
+ if spec.OCRKeyBundleID.Valid {
+ info.OcrKeyBundleId = proto.String(spec.OCRKeyBundleID.String)
+ }
+
+ if spec.Relay == "evm" {
+ evmCfg, err := buildEVMRelayConfig(relayConfigRaw, ocr2ChainID(spec), spec.TransmitterID.ValueOrZero())
+ if err != nil {
+ return nil, fmt.Errorf("building EVM relay config: %w", err)
+ }
+ info.EvmRelayConfig = evmCfg
+ }
+
+ if spec.PluginType == commontypes.Median {
+ medianCfg, err := buildMedianPluginConfig(pluginConfigRaw)
+ if err != nil {
+ return nil, fmt.Errorf("building median plugin config: %w", err)
+ }
+ info.MedianPluginConfig = medianCfg
+ }
+
+ return info, nil
+}
+
+// buildEVMRelayConfig decodes the EVM relay config JSON into OCR2EVMRelayConfig.
+func buildEVMRelayConfig(relayConfigJSON []byte, chainID, transmitterID string) (*events.OCR2EVMRelayConfig, error) {
+ var cfg evmRelayConfig
+ if err := json.Unmarshal(relayConfigJSON, &cfg); err != nil {
+ return nil, fmt.Errorf("unmarshaling EVM relay config: %w", err)
+ }
+
+ effectiveTransmitterID := transmitterID
+ if cfg.EffectiveTransmitterID != nil {
+ effectiveTransmitterID = *cfg.EffectiveTransmitterID
+ }
+
+ evmProto := &events.OCR2EVMRelayConfig{
+ ChainId: chainID,
+ EffectiveTransmitterId: effectiveTransmitterID,
+ SendingKeys: cfg.SendingKeys,
+ }
+ if cfg.FromBlock != nil {
+ evmProto.FromBlock = proto.Uint64(*cfg.FromBlock)
+ }
+ if cfg.EnableDualTransmission != nil {
+ evmProto.EnableDualTransmission = proto.Bool(*cfg.EnableDualTransmission)
+ }
+ if cfg.EnableTriggerCapability != nil {
+ evmProto.EnableTriggerCapability = proto.Bool(*cfg.EnableTriggerCapability)
+ }
+ if cfg.LLODonID != nil {
+ evmProto.LloDonId = proto.Uint64(*cfg.LLODonID)
+ }
+ if cfg.FeedID != nil {
+ evmProto.FeedId = proto.String(*cfg.FeedID)
+ }
+ if cfg.ProviderType != nil {
+ evmProto.ProviderType = proto.String(*cfg.ProviderType)
+ }
+ return evmProto, nil
+}
+
+// buildMedianPluginConfig decodes the median plugin config JSON into OCR2MedianPluginConfig.
+func buildMedianPluginConfig(pluginConfigJSON []byte) (*events.OCR2MedianPluginConfig, error) {
+ var cfg medianPluginConfig
+ if err := json.Unmarshal(pluginConfigJSON, &cfg); err != nil {
+ return nil, fmt.Errorf("unmarshaling median plugin config: %w", err)
+ }
+
+ medianProto := &events.OCR2MedianPluginConfig{}
+ if cfg.JuelsPerFeeCoinPipeline != nil {
+ medianProto.JuelsPerFeeCoinSource = *cfg.JuelsPerFeeCoinPipeline
+ }
+
+ return medianProto, nil
+}
diff --git a/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go b/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go
new file mode 100644
index 00000000000..4c2c2c3d375
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go
@@ -0,0 +1,392 @@
+package jobspec_test
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "gopkg.in/guregu/null.v4"
+
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder/beholdertest"
+ commontypes "github.com/smartcontractkit/chainlink-common/pkg/types"
+
+ "github.com/smartcontractkit/chainlink/v2/core/logger"
+ "github.com/smartcontractkit/chainlink/v2/core/services/feeds"
+ feedsmocks "github.com/smartcontractkit/chainlink/v2/core/services/feeds/mocks"
+ "github.com/smartcontractkit/chainlink/v2/core/services/job"
+ jobmocks "github.com/smartcontractkit/chainlink/v2/core/services/job/mocks"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events"
+ "github.com/smartcontractkit/chainlink/v2/core/services/pipeline"
+)
+
+// stubConfig implements config.JobSpecReporter for tests.
+type stubConfig struct {
+ enabled bool
+ pollingInterval time.Duration
+ enabledOCR2PluginTypes []string
+}
+
+func (s *stubConfig) Enabled() bool { return s.enabled }
+func (s *stubConfig) PollingInterval() time.Duration { return s.pollingInterval }
+func (s *stubConfig) EnabledOCR2PluginTypes() []string { return s.enabledOCR2PluginTypes }
+
+func defaultConfig() *stubConfig {
+ return &stubConfig{
+ enabled: true,
+ pollingInterval: time.Hour,
+ enabledOCR2PluginTypes: []string{"median"},
+ }
+}
+
+func makeMedianJob() job.Job {
+ return job.Job{
+ ID: 1,
+ ExternalJobID: uuid.New(),
+ Name: null.StringFrom("test-median-job"),
+ Type: job.OffchainReporting2,
+ SchemaVersion: 1,
+ PipelineSpec: &pipeline.Spec{
+ ID: 10,
+ DotDagSource: `ds1 [type=bridge name="my-bridge"]`,
+ },
+ Pipeline: pipeline.Pipeline{
+ Tasks: []pipeline.Task{
+ &pipeline.BridgeTask{
+ BaseTask: pipeline.NewBaseTask(0, "ds1", nil, nil, 0),
+ Name: "my-bridge",
+ },
+ },
+ },
+ OCR2OracleSpec: &job.OCR2OracleSpec{
+ ID: 1,
+ ContractID: "0x1234567890abcdef",
+ Relay: "evm",
+ ChainID: "1",
+ PluginType: commontypes.Median,
+ TransmitterID: null.StringFrom("0x1111111111111111111111111111111111111111"),
+ RelayConfig: job.JSONConfig{"chainID": "1"},
+ PluginConfig: job.JSONConfig{"juelsPerFeeCoinSource": `ds1 [type=http method=GET url="https://example.com"]`},
+ OnchainSigningStrategy: job.JSONConfig{},
+ P2PV2Bootstrappers: []string{"12D3KooW@host:6688"},
+ ContractConfigConfirmations: 1,
+ },
+ CreatedAt: time.Now(),
+ }
+}
+
+func makeNonMedianOCR2Job() job.Job {
+ jb := makeMedianJob()
+ jb.ID = 2
+ jb.ExternalJobID = uuid.New()
+ jb.Name = null.StringFrom("test-non-median-job")
+ jb.OCR2OracleSpec = &job.OCR2OracleSpec{
+ ID: 2,
+ ContractID: "0xabcdef1234567890",
+ Relay: "evm",
+ ChainID: "1",
+ PluginType: commontypes.Mercury,
+ TransmitterID: null.StringFrom("0x2222222222222222222222222222222222222222"),
+ RelayConfig: job.JSONConfig{"chainID": "1"},
+ PluginConfig: job.JSONConfig{},
+ OnchainSigningStrategy: job.JSONConfig{},
+ }
+ return jb
+}
+
+func makeNonOCR2Job() job.Job {
+ return job.Job{
+ ID: 3,
+ ExternalJobID: uuid.New(),
+ Name: null.StringFrom("test-cron-job"),
+ Type: job.Cron,
+ SchemaVersion: 1,
+ PipelineSpec: &pipeline.Spec{ID: 30, DotDagSource: ""},
+ Pipeline: pipeline.Pipeline{},
+ CreatedAt: time.Now(),
+ }
+}
+
+// newTestReporter returns a Service wired to the current global beholder emitter.
+// The caller must set up the test emitter via beholdertest.NewObserver(t) first.
+func newTestReporter(t *testing.T, cfg *stubConfig, feedsORM feeds.ORM) *jobspec.Service {
+ t.Helper()
+ spawner := jobmocks.NewSpawner(t)
+ return jobspec.NewJobSpecReporter(cfg, spawner, feedsORM, beholder.GetEmitter(), "csa-key", "1.0.0", "test-host", logger.TestLogger(t))
+}
+
+// newFeedsORMWithoutProposal returns a feeds ORM mock that behaves as if the
+// given job was created outside of the Feeds Manager.
+func newFeedsORMWithoutProposal(t *testing.T, jb job.Job) *feedsmocks.ORM {
+ t.Helper()
+ feedsORM := feedsmocks.NewORM(t)
+ feedsORM.On("GetJobProposalByExternalJobID", mock.Anything, jb.ExternalJobID).Return(nil, sql.ErrNoRows).Maybe()
+ return feedsORM
+}
+
+func requireSingleJobSpecEvent(t *testing.T, observer beholdertest.Observer) *events.JobSpecEvent {
+ t.Helper()
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ ev := new(events.JobSpecEvent)
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, ev))
+ return ev
+}
+
+func TestShouldEmit_DefaultConfig(t *testing.T) {
+ beholdertest.NewObserver(t)
+ svc := newTestReporter(t, defaultConfig(), nil)
+
+ median := makeMedianJob()
+ nonMedian := makeNonMedianOCR2Job()
+ nonOCR2 := makeNonOCR2Job()
+
+ cases := []struct {
+ name string
+ jb *job.Job
+ want bool
+ }{
+ {"median OCR2 job emits", &median, true},
+ {"non-median OCR2 job skipped", &nonMedian, false},
+ {"non-OCR2 job skipped", &nonOCR2, false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, svc.ShouldEmit(tc.jb))
+ })
+ }
+}
+
+func TestShouldEmit_NoOCR2Types(t *testing.T) {
+ beholdertest.NewObserver(t)
+ cfg := defaultConfig()
+ cfg.enabledOCR2PluginTypes = []string{} // empty allowlist = disable all
+
+ svc := newTestReporter(t, cfg, nil)
+
+ median := makeMedianJob()
+ nonMedian := makeNonMedianOCR2Job()
+ nonOCR2 := makeNonOCR2Job()
+
+ assert.False(t, svc.ShouldEmit(&median))
+ assert.False(t, svc.ShouldEmit(&nonMedian))
+ assert.False(t, svc.ShouldEmit(&nonOCR2))
+}
+
+func TestShouldEmit_AllOCR2Types(t *testing.T) {
+ beholdertest.NewObserver(t)
+ cfg := defaultConfig()
+ cfg.enabledOCR2PluginTypes = []string{"all"}
+
+ svc := newTestReporter(t, cfg, nil)
+
+ median := makeMedianJob()
+ nonMedian := makeNonMedianOCR2Job()
+ nonOCR2 := makeNonOCR2Job()
+
+ assert.True(t, svc.ShouldEmit(&median))
+ assert.True(t, svc.ShouldEmit(&nonMedian))
+ assert.False(t, svc.ShouldEmit(&nonOCR2))
+}
+
+func TestShouldEmit_NonOCR2Skipped(t *testing.T) {
+ beholdertest.NewObserver(t)
+ cfg := defaultConfig()
+
+ svc := newTestReporter(t, cfg, nil)
+
+ median := makeMedianJob()
+ nonOCR2 := makeNonOCR2Job()
+
+ assert.False(t, svc.ShouldEmit(&nonOCR2))
+ assert.True(t, svc.ShouldEmit(&median))
+}
+
+func TestBuildEvent_MedianJob(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ assert.Equal(t, jb.ExternalJobID.String(), ev.ExternalJobId)
+ assert.Equal(t, "test-median-job", ev.Name)
+ assert.Equal(t, "offchainreporting2", ev.JobType)
+ assert.Equal(t, jb.CreatedAt.Format(time.RFC3339Nano), ev.CreatedAt)
+ assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT, ev.EmissionTrigger)
+ assert.Equal(t, "csa-key", ev.CsaPublicKey)
+ assert.Equal(t, "1.0.0", ev.NodeVersion)
+ assert.Equal(t, "test-host", ev.Hostname)
+ assert.Equal(t, []string{"my-bridge"}, ev.BridgeNames)
+ require.NotNil(t, ev.Ocr2OracleSpec)
+ assert.Equal(t, "0x1234567890abcdef", ev.Ocr2OracleSpec.GetContractId())
+ assert.Equal(t, "evm", ev.Ocr2OracleSpec.Relay)
+ assert.Equal(t, "median", ev.Ocr2OracleSpec.PluginType)
+ require.NotNil(t, ev.Ocr2OracleSpec.MedianPluginConfig)
+ assert.NotEmpty(t, ev.Ocr2OracleSpec.MedianPluginConfig.GetJuelsPerFeeCoinSource())
+ require.NotNil(t, ev.Ocr2OracleSpec.EvmRelayConfig)
+ assert.Equal(t, "1", ev.Ocr2OracleSpec.EvmRelayConfig.GetChainId())
+ assert.Equal(t, "0x1111111111111111111111111111111111111111", ev.Ocr2OracleSpec.EvmRelayConfig.GetEffectiveTransmitterId())
+}
+
+func TestBuildEvent_MedianJobBridgeNamesFromPipelineSpec(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ jb.Pipeline = pipeline.Pipeline{}
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ assert.Equal(t, []string{"my-bridge"}, ev.BridgeNames)
+}
+
+func TestBuildEvent_MedianJobNumericRelayConfigChainID(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ jb.OCR2OracleSpec.ChainID = ""
+ jb.OCR2OracleSpec.RelayConfig["chainID"] = int64(11155111)
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ require.NotNil(t, ev.Ocr2OracleSpec)
+ require.NotNil(t, ev.Ocr2OracleSpec.EvmRelayConfig)
+ assert.Equal(t, "11155111", ev.Ocr2OracleSpec.EvmRelayConfig.GetChainId())
+}
+
+func TestBuildEvent_EVMRelayConfigEmitsExplicitFalseBooleans(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ jb.OCR2OracleSpec.RelayConfig["enableDualTransmission"] = false
+ jb.OCR2OracleSpec.RelayConfig["enableTriggerCapability"] = false
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ require.NotNil(t, ev.Ocr2OracleSpec)
+ require.NotNil(t, ev.Ocr2OracleSpec.EvmRelayConfig)
+ assert.False(t, ev.Ocr2OracleSpec.EvmRelayConfig.GetEnableDualTransmission())
+ assert.False(t, ev.Ocr2OracleSpec.EvmRelayConfig.GetEnableTriggerCapability())
+ assert.NotNil(t, ev.Ocr2OracleSpec.EvmRelayConfig.EnableDualTransmission)
+ assert.NotNil(t, ev.Ocr2OracleSpec.EvmRelayConfig.EnableTriggerCapability)
+}
+
+func TestBuildEvent_NonMedianOCR2Job(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeNonMedianOCR2Job()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_CREATE)
+ require.NoError(t, err)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ require.NotNil(t, ev.Ocr2OracleSpec)
+ assert.Equal(t, "mercury", ev.Ocr2OracleSpec.PluginType)
+ assert.Nil(t, ev.Ocr2OracleSpec.MedianPluginConfig)
+ assert.NotEmpty(t, ev.Ocr2OracleSpec.RelayConfigJson)
+}
+
+func TestBuildEvent_NonOCR2Job(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ svc := newTestReporter(t, defaultConfig(), nil)
+
+ jb := makeNonOCR2Job()
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.ErrorContains(t, err, "unsupported job type")
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Empty(t, msgs)
+}
+
+func TestAfterJobStarted_EmitsCreate(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+ svc.AfterJobStarted(context.Background(), jb)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_CREATE, ev.EmissionTrigger)
+}
+
+func TestAfterJobStopped_EmitsDelete(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+ svc.AfterJobStopped(context.Background(), jb)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_DELETE, ev.EmissionTrigger)
+}
+
+func TestAfterJobStarted_SkippedWhenGateFails(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ // default config only allows median, so a non-OCR2 job should be skipped
+ svc := newTestReporter(t, defaultConfig(), nil)
+ svc.AfterJobStarted(context.Background(), makeNonOCR2Job())
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Empty(t, msgs)
+}
+
+func TestBuildEvent_ProposalLifecycle(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+ feedsORM := feedsmocks.NewORM(t)
+
+ jb := makeMedianJob()
+ proposedAt := time.Now().Add(-5 * time.Minute)
+ approvedAt := time.Now().Add(-2 * time.Minute)
+
+ prop := &feeds.JobProposal{
+ ID: 100,
+ FeedsManagerID: 7,
+ RemoteUUID: uuid.New(),
+ }
+ spec := &feeds.JobProposalSpec{
+ ID: 200,
+ Version: 3,
+ CreatedAt: proposedAt,
+ StatusUpdatedAt: approvedAt,
+ }
+
+ feedsORM.On("GetJobProposalByExternalJobID", mock.Anything, jb.ExternalJobID).Return(prop, nil)
+ feedsORM.On("GetApprovedSpec", mock.Anything, prop.ID).Return(spec, nil)
+
+ svc := newTestReporter(t, defaultConfig(), feedsORM)
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ ev := requireSingleJobSpecEvent(t, observer)
+ assert.Equal(t, int64(7), ev.FeedsManagerId)
+ assert.Equal(t, prop.RemoteUUID.String(), ev.RemoteUuid)
+ assert.Equal(t, int32(3), ev.SpecVersion)
+ assert.Equal(t, proposedAt.Format(time.RFC3339Nano), ev.ProposedAt)
+ assert.Equal(t, approvedAt.Format(time.RFC3339Nano), ev.ApprovedAt)
+ assert.InDelta(t, approvedAt.Sub(proposedAt).Seconds(), ev.AcceptLatencySeconds, 1.0)
+}
diff --git a/core/services/pipeline/bridge_required_paths_test.go b/core/services/pipeline/bridge_required_paths_test.go
new file mode 100644
index 00000000000..96a6fffa58b
--- /dev/null
+++ b/core/services/pipeline/bridge_required_paths_test.go
@@ -0,0 +1,342 @@
+package pipeline
+
+import (
+ "testing"
+
+ "github.com/buger/jsonparser"
+ "github.com/stretchr/testify/require"
+)
+
+// TestingSetBridgeRequiredJSONPaths sets required JSON paths on a bridge task
+// for tests in external test packages (e.g. pipeline_test).
+func TestingSetBridgeRequiredJSONPaths(t *BridgeTask, paths [][]string) {
+ t.requiredJSONPaths = paths
+}
+
+func TestRequiredJSONPathsFromBridge_DirectEdge(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b [type=bridge name=testbridge];
+p [type=jsonparse path="data,result"];
+b -> p;
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+
+ paths := bt.getRequiredJSONPaths()
+ require.Equal(t, [][]string{{"data", "result"}}, paths)
+}
+
+func TestRequiredJSONPathsFromBridge_DataVarRoot(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b [type=bridge name=testbridge];
+p [type=jsonparse path="a,b" data="$(b)"];
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+
+ paths := bt.getRequiredJSONPaths()
+ require.Equal(t, [][]string{{"a", "b"}}, paths)
+}
+
+func TestRequiredJSONPathsFromBridge_CustomSeparator(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b [type=bridge name=testbridge];
+p [type=jsonparse path="a|b|c" separator="|"];
+b -> p;
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+
+ paths := bt.getRequiredJSONPaths()
+ require.Equal(t, [][]string{{"a", "b", "c"}}, paths)
+}
+
+func TestRequiredJSONPathsFromBridge_SkipsLax(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b [type=bridge name=testbridge];
+p [type=jsonparse path="data,result" lax="true"];
+b -> p;
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+
+ paths := bt.getRequiredJSONPaths()
+ require.Nil(t, paths)
+}
+
+func TestRequiredJSONPathsFromBridge_SkipsDynamicPath(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b [type=bridge name=testbridge];
+p [type=jsonparse path="$(jobRun.x)"];
+b -> p;
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+
+ paths := bt.getRequiredJSONPaths()
+ require.Nil(t, paths)
+}
+
+func TestRequiredJSONPathsFromBridge_LowestOutputIndexWins(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b0 [type=bridge name=a index=0];
+b1 [type=bridge name=b index=1];
+p [type=jsonparse path="only,b0"];
+b0 -> p;
+b1 -> p;
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var b0, b1 *BridgeTask
+ for _, tk := range pl.Tasks {
+ switch tk.DotID() {
+ case "b0":
+ b0 = tk.(*BridgeTask)
+ case "b1":
+ b1 = tk.(*BridgeTask)
+ }
+ }
+ require.NotNil(t, b0)
+ require.NotNil(t, b1)
+
+ require.Equal(t, [][]string{{"only", "b0"}}, b0.getRequiredJSONPaths())
+ require.Nil(t, b1.getRequiredJSONPaths())
+}
+
+func TestRequiredJSONPathsFromBridge_DedupesPaths(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b [type=bridge name=testbridge];
+p1 [type=jsonparse path="data,result"];
+p2 [type=jsonparse path="data,result"];
+b -> p1;
+b -> p2;
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+
+ paths := bt.getRequiredJSONPaths()
+ require.Equal(t, [][]string{{"data", "result"}}, paths)
+}
+
+func TestRequiredJSONPathsFromBridge_EmptyPathSkipped(t *testing.T) {
+ t.Parallel()
+
+ dot := `
+b [type=bridge name=testbridge];
+p [type=jsonparse path="" ];
+b -> p;
+`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+
+ paths := bt.getRequiredJSONPaths()
+ require.Nil(t, paths)
+}
+
+func TestJSONDecodeValidateRequiredPaths(t *testing.T) {
+ t.Parallel()
+
+ t.Run("empty_paths_noop", func(t *testing.T) {
+ t.Parallel()
+ require.NoError(t, jsonDecodeValidateRequiredPaths([]byte(`{}`), nil))
+ require.NoError(t, jsonDecodeValidateRequiredPaths([]byte(`{}`), [][]string{}))
+ })
+
+ t.Run("json_number_and_string_ok", func(t *testing.T) {
+ t.Parallel()
+ require.NoError(t, jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":42}}`), [][]string{{"data", "result"}}))
+ require.NoError(t, jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"123.45"}}`), [][]string{{"data", "result"}}))
+ })
+
+ t.Run("large_finite_decimal_json_number", func(t *testing.T) {
+ t.Parallel()
+ // Decimal accepts magnitudes float64 cannot represent.
+ body := []byte(`{"data":{"result":1e309}}`)
+ require.NoError(t, jsonDecodeValidateRequiredPaths(body, [][]string{{"data", "result"}}))
+ })
+
+ t.Run("missing_path", func(t *testing.T) {
+ t.Parallel()
+ body := []byte(`{"data":{"result":1}}`)
+ err := jsonDecodeValidateRequiredPaths(body, [][]string{{"data", "missing"}})
+ require.Error(t, err)
+ require.ErrorIs(t, err, jsonparser.KeyPathNotFoundError)
+ })
+
+ t.Run("null_value", func(t *testing.T) {
+ t.Parallel()
+ body := []byte(`{"data":{"result":null}}`)
+ err := jsonDecodeValidateRequiredPaths(body, [][]string{{"data", "result"}})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "is null")
+ })
+
+ t.Run("scientific_notation_rejected_after_decimal_fails", func(t *testing.T) {
+ t.Parallel()
+ // Decimal rejects huge exponent; must not be accepted via a hex fallback (digits+e are valid hex).
+ body := []byte(`{"data":{"result":1e10000000000}}`)
+ err := jsonDecodeValidateRequiredPaths(body, [][]string{{"data", "result"}})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid value")
+ })
+
+ t.Run("nan_string_rejected", func(t *testing.T) {
+ t.Parallel()
+ err := jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"NaN"}}`), [][]string{{"data", "result"}})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "NaN")
+
+ require.Error(t, jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"nan"}}`), [][]string{{"data", "result"}}))
+ })
+
+ t.Run("hex_literals", func(t *testing.T) {
+ t.Parallel()
+ require.NoError(t, jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"0xff"}}`), [][]string{{"data", "result"}}))
+ require.NoError(t, jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"0XAbCd"}}`), [][]string{{"data", "result"}}))
+ require.NoError(t, jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"-0x10"}}`), [][]string{{"data", "result"}}))
+ })
+
+ t.Run("large_hex_integer", func(t *testing.T) {
+ t.Parallel()
+ body := []byte(`{"data":{"result":"0x1fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}}`)
+ require.NoError(t, jsonDecodeValidateRequiredPaths(body, [][]string{{"data", "result"}}))
+ })
+
+ t.Run("invalid_hex", func(t *testing.T) {
+ t.Parallel()
+ err := jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"0x"}}`), [][]string{{"data", "result"}})
+ require.Error(t, err)
+ err = jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"0xGG"}}`), [][]string{{"data", "result"}})
+ require.Error(t, err)
+ })
+
+ t.Run("non_numeric_string_rejected", func(t *testing.T) {
+ t.Parallel()
+ err := jsonDecodeValidateRequiredPaths(
+ []byte(`{"data":{"result":"ok"}}`), [][]string{{"data", "result"}})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "invalid value")
+ })
+
+ t.Run("multiple_paths", func(t *testing.T) {
+ t.Parallel()
+ body := []byte(`{"a":1,"b":"0x2a"}`)
+ require.NoError(t, jsonDecodeValidateRequiredPaths(body, [][]string{{"a"}, {"b"}}))
+ })
+
+ t.Run("empty_path_segment_skipped", func(t *testing.T) {
+ t.Parallel()
+ // jsonDecodeValidateRequiredPaths ignores empty segment lists (same as no required paths).
+ require.NoError(t, jsonDecodeValidateRequiredPaths(
+ []byte(`{}`), [][]string{{}}))
+ })
+}
+
+func TestRequiredJSONPathsFromBridge_NilTask(t *testing.T) {
+ t.Parallel()
+ require.Nil(t, ((*BridgeTask)(nil)).getRequiredJSONPaths())
+}
+
+func TestParseBridgeTask_CheckRequired(t *testing.T) {
+ t.Parallel()
+
+ dot := `b [type=bridge name=testbridge checkRequired=true];`
+ pl, err := Parse(dot)
+ require.NoError(t, err)
+ var bt *BridgeTask
+ for _, tk := range pl.Tasks {
+ if tk.DotID() == "b" {
+ bt = tk.(*BridgeTask)
+ break
+ }
+ }
+ require.NotNil(t, bt)
+ require.Equal(t, "true", bt.CheckRequired)
+}
diff --git a/core/services/pipeline/jsonpath_traverse.go b/core/services/pipeline/jsonpath_traverse.go
new file mode 100644
index 00000000000..1db6672f5ac
--- /dev/null
+++ b/core/services/pipeline/jsonpath_traverse.go
@@ -0,0 +1,73 @@
+package pipeline
+
+import (
+ "math/big"
+ "strings"
+
+ "github.com/pkg/errors"
+)
+
+// traverseJSONPath walks decoded JSON (map/slice tree) along path the same way
+// JSONParseTask does. When lax is false, a missing segment returns ErrKeypathNotFound.
+func traverseJSONPath(decoded any, path []string, lax bool) (any, error) {
+ for _, part := range path {
+ switch d := decoded.(type) {
+ case map[string]any:
+ var exists bool
+ decoded, exists = d[part]
+ if !exists && lax {
+ decoded = nil
+ break
+ } else if !exists {
+ return nil, errJSONPathNotFound(path)
+ }
+
+ case []any:
+ next, laxMiss, err := jsonPathArrayStep(d, part, path, lax)
+ if err != nil {
+ return nil, err
+ }
+ if laxMiss {
+ decoded = nil
+ break
+ }
+ decoded = next
+
+ default:
+ return nil, errJSONPathNotFound(path)
+ }
+ }
+ return decoded, nil
+}
+
+func errJSONPathNotFound(path []string) error {
+ return errors.Wrapf(ErrKeypathNotFound, `could not resolve path ["%v"]`, strings.Join(path, `","`))
+}
+
+// jsonPathArrayStep resolves one path segment against a JSON array. laxMiss is true when lax
+// mode treats the segment as missing (decoded becomes nil). err is set for invalid index syntax
+// or strict resolution failure.
+func jsonPathArrayStep(d []any, part string, path []string, lax bool) (next any, laxMiss bool, err error) {
+ bi, ok := big.NewInt(0).SetString(part, 10)
+ if !ok {
+ return nil, false, errors.Wrapf(ErrKeypathNotFound, "JSONParse task error: %v is not a valid array index", part)
+ }
+ if !bi.IsInt64() {
+ if lax {
+ return nil, true, nil
+ }
+ return nil, false, errJSONPathNotFound(path)
+ }
+
+ index := int(bi.Int64())
+ if index < 0 {
+ index = len(d) + index
+ }
+ if index < 0 || index >= len(d) {
+ if lax {
+ return nil, true, nil
+ }
+ return nil, false, errJSONPathNotFound(path)
+ }
+ return d[index], false, nil
+}
diff --git a/core/services/pipeline/models.go b/core/services/pipeline/models.go
index 66f5a529a3f..9d970feab89 100644
--- a/core/services/pipeline/models.go
+++ b/core/services/pipeline/models.go
@@ -19,7 +19,9 @@ import (
)
type Spec struct {
- ID int32
+ ID int32
+ // DotDagSource is the pipeline graph in DOT notation. Bridge tasks may set
+ // checkRequired=true on a node to validate required JSON paths for cache fallback.
DotDagSource string `json:"dotDagSource"`
CreatedAt time.Time `json:"-"`
MaxTaskDuration sqlutil.Interval `json:"-"`
diff --git a/core/services/pipeline/runner.go b/core/services/pipeline/runner.go
index 04e86072d70..72e535687b9 100644
--- a/core/services/pipeline/runner.go
+++ b/core/services/pipeline/runner.go
@@ -339,15 +339,17 @@ func (r *runner) InitializePipeline(spec Spec) (pipeline *Pipeline, err error) {
task.(*HTTPTask).httpClient = r.httpClient
task.(*HTTPTask).unrestrictedHTTPClient = r.unrestrictedHTTPClient
case TaskTypeBridge:
- task.(*BridgeTask).config = r.config
- task.(*BridgeTask).bridgeConfig = r.bridgeConfig
+ bt := task.(*BridgeTask)
+ bt.config = r.config
+ bt.bridgeConfig = r.bridgeConfig
// orm added to BridgeTask
- task.(*BridgeTask).orm = r.btORM
- task.(*BridgeTask).specId = spec.ID
+ bt.orm = r.btORM
+ bt.specId = spec.ID
// URL is "safe" because it comes from the node's own database. We
// must use the unrestrictedHTTPClient because some node operators
// may run external adapters on their own hardware
- task.(*BridgeTask).httpClient = r.unrestrictedHTTPClient
+ bt.httpClient = r.unrestrictedHTTPClient
+ bt.requiredJSONPaths = bt.getRequiredJSONPaths()
case TaskTypeETHCall:
task.(*ETHCallTask).legacyChains = r.legacyEVMChains
task.(*ETHCallTask).config = r.config
diff --git a/core/services/pipeline/task.bridge.go b/core/services/pipeline/task.bridge.go
index 8d9bd82fb26..d19e0993a9d 100644
--- a/core/services/pipeline/task.bridge.go
+++ b/core/services/pipeline/task.bridge.go
@@ -5,15 +5,22 @@ import (
"database/sql"
stderrors "errors"
"maps"
+ "math"
+ "math/big"
"net/http"
"net/url"
"path"
+ "regexp"
+ "strconv"
+ "strings"
"time"
+ "github.com/buger/jsonparser"
"github.com/goccy/go-json"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
+ "github.com/shopspring/decimal"
"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink/v2/core/bridges"
@@ -73,12 +80,31 @@ type BridgeTask struct {
Async string `json:"async"`
CacheTTL string `json:"cacheTTL"`
Headers string `json:"headers"`
+ // CheckRequired when "true" enables validation that the HTTP response JSON
+ // contains paths required by strict downstream jsonparse tasks (see
+ // requiredJSONPaths). When empty or "false", that check is skipped.
+ CheckRequired string `json:"checkRequired"`
specId int32
orm bridges.ORM
config Config
bridgeConfig BridgeConfig
httpClient *http.Client
+
+ // requiredJSONPaths is populated in runner.InitializePipeline from strict
+ // downstream jsonparse tasks. When CheckRequired is true and cacheTTL is set,
+ // validation uses these paths to fall back to cache when the live response
+ // omits required keys.
+ requiredJSONPaths [][]string
+}
+
+// bridgeHTTPOutcome holds response state after the HTTP round-trip through EA JSON status,
+// optional required-path validation, and optional cache fallback.
+type bridgeHTTPOutcome struct {
+ body []byte
+ statusCode int
+ err error
+ cachedResponse bool
}
type BridgeTelemetry struct {
@@ -115,6 +141,7 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp
includeInputAtKey StringParam
cacheTTL Uint64Param
reqHeaders StringSliceParam
+ checkRequired BoolParam
)
err = stderrors.Join(
errors.Wrap(ResolveParam(&name, From(NonemptyString(t.Name))), "name"),
@@ -122,6 +149,7 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp
errors.Wrap(ResolveParam(&includeInputAtKey, From(t.IncludeInputAtKey)), "includeInputAtKey"),
errors.Wrap(ResolveParam(&cacheTTL, From(ValidDurationInSeconds(t.CacheTTL), t.bridgeConfig.BridgeCacheTTL().Seconds())), "cacheTTL"),
errors.Wrap(ResolveParam(&reqHeaders, From(NonemptyString(t.Headers), "[]")), "reqHeaders"),
+ errors.Wrap(ResolveParam(&checkRequired, From(NonemptyString(t.CheckRequired), false)), "checkRequired"),
)
if err != nil {
return Result{Error: err}, runInfo
@@ -139,40 +167,7 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp
return Result{Error: err}, runInfo
}
- var metaMap MapParam
-
- meta, _ := vars.Get("jobRun.meta")
- switch v := meta.(type) {
- case map[string]any:
- metaMap = MapParam(v)
- case nil:
- default:
- lggr.Warnw(`"meta" field on task run is malformed, discarding`,
- "task", t.DotID(),
- "meta", meta,
- )
- }
-
- requestData = withRunInfo(requestData, metaMap)
- if t.IncludeInputAtKey != "" {
- if len(inputValues) > 0 {
- requestData[string(includeInputAtKey)] = inputValues[0]
- }
- }
-
- if t.Async == "true" {
- responseURL := t.bridgeConfig.BridgeResponseURL()
- if responseURL != nil && *responseURL != *zeroURL {
- responseURL.Path = path.Join(responseURL.Path, "/v2/resume/", t.uuid.String())
- }
- var s string
- if responseURL != nil {
- s = responseURL.String()
- }
- requestData["responseURL"] = s
- }
-
- requestDataJSON, err := json.Marshal(requestData)
+ requestDataJSON, err := t.finalizeAndMarshalBridgeRequestData(lggr, vars, inputValues, &requestData, includeInputAtKey)
if err != nil {
return Result{Error: err}, runInfo
}
@@ -190,7 +185,16 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp
promBridgeLatency.WithLabelValues(t.Name, statusCodeGroup(statusCode)).Set(elapsed.Seconds())
promBridgeLatencyHist.WithLabelValues(t.Name, statusCodeGroup(statusCode)).Observe(float64(elapsed.Milliseconds()))
+ out := bridgeHTTPOutcome{
+ body: responseBytes,
+ statusCode: statusCode,
+ err: err,
+ cachedResponse: false,
+ }
+
defer func() {
+ // Runs when Run returns; reads the final err, responseBytes, statusCode, and cachedResponse
+ // after EA JSON handling, required-path validation, and optional cache fallback.
telemetryCh := GetTelemetryCh(ctx)
if telemetryCh != nil {
bt := &BridgeTelemetry{
@@ -219,47 +223,20 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp
}
}()
- // check for external adapter response object status
- if code, ok := eautils.BestEffortExtractEAStatus(responseBytes); ok {
- statusCode = code
- }
-
- if err != nil || statusCode != http.StatusOK {
- if adapterErr := eautils.BestEffortExtractEAError(responseBytes); adapterErr != nil {
- err = adapterErr
- }
-
- promBridgeErrors.WithLabelValues(t.Name).Inc()
- if cacheTTL == 0 {
- lggr.Debugw("Bridge task: request failed",
- "response", string(responseBytes),
- "url", url.String(),
- "status_code", statusCode,
- "error", err,
- )
- return Result{Error: err}, RunInfo{IsRetryable: isRetryableHTTPError(statusCode, err)}
- }
+ out.statusCode = eaJSONResponseStatus(out.body, out.statusCode)
+ liveOK := out.err == nil && out.statusCode == http.StatusOK
+ out.err = t.maybeValidateRequiredJSONPaths(out.err, liveOK, cacheTTL, checkRequired, out.body)
- var cacheErr error
- responseBytes, cacheErr = t.orm.GetCachedResponse(overtimeCtx, t.dotID, t.specId, time.Duration(cacheTTL)*time.Second)
- if cacheErr != nil {
- promBridgeCacheErrors.WithLabelValues(t.Name).Inc()
- if !errors.Is(cacheErr, sql.ErrNoRows) {
- lggr.Warnw("Bridge task: cache fallback failed",
- "err", cacheErr.Error(),
- "url", url.String(),
- )
- }
- return Result{Error: err}, RunInfo{IsRetryable: isRetryableHTTPError(statusCode, err)}
- }
- promBridgeCacheHits.WithLabelValues(t.Name).Inc()
- lggr.Debugw("Bridge task: request failed, falling back to cache",
- "response", string(responseBytes),
- "url", url.String(),
- )
- cachedResponse = true
+ out, earlyResult, earlyRunInfo := t.resolveFailureOrCache(overtimeCtx, lggr, url, out, cacheTTL)
+ if earlyResult != nil {
+ return *earlyResult, *earlyRunInfo
}
+ responseBytes = out.body
+ statusCode = out.statusCode
+ err = out.err
+ cachedResponse = out.cachedResponse
+
if t.Async == "true" {
// Look for a `pending` flag. This check is case-insensitive because http.Header normalizes header names
if _, ok := headers["X-Chainlink-Pending"]; ok {
@@ -299,6 +276,122 @@ func (t *BridgeTask) Run(ctx context.Context, lggr logger.Logger, vars Vars, inp
return result, runInfo
}
+// finalizeAndMarshalBridgeRequestData merges job meta, upstream inputs, and async resume URL into requestData,
+// writes the merged map back through requestData for use by makeHTTPRequest, and returns the JSON body for logging
+// and telemetry.
+func (t *BridgeTask) finalizeAndMarshalBridgeRequestData(lggr logger.Logger, vars Vars, inputValues []any, requestData *MapParam, includeInputAtKey StringParam) ([]byte, error) {
+ var metaMap MapParam
+
+ meta, _ := vars.Get("jobRun.meta")
+ switch v := meta.(type) {
+ case map[string]any:
+ metaMap = MapParam(v)
+ case nil:
+ default:
+ lggr.Warnw(`"meta" field on task run is malformed, discarding`,
+ "task", t.DotID(),
+ "meta", meta,
+ )
+ }
+
+ merged := withRunInfo(*requestData, metaMap)
+ if t.IncludeInputAtKey != "" {
+ if len(inputValues) > 0 {
+ merged[string(includeInputAtKey)] = inputValues[0]
+ }
+ }
+
+ if t.Async == "true" {
+ responseURL := t.bridgeConfig.BridgeResponseURL()
+ if responseURL != nil && *responseURL != *zeroURL {
+ responseURL.Path = path.Join(responseURL.Path, "/v2/resume/", t.uuid.String())
+ }
+ var s string
+ if responseURL != nil {
+ s = responseURL.String()
+ }
+ merged["responseURL"] = s
+ }
+
+ *requestData = merged
+ return json.Marshal(merged)
+}
+
+// eaJSONResponseStatus returns the external-adapter status from the response body when present, otherwise the
+// HTTP status from the round-trip.
+func eaJSONResponseStatus(body []byte, httpStatus int) int {
+ if code, ok := eautils.BestEffortExtractEAStatus(body); ok {
+ return code
+ }
+ return httpStatus
+}
+
+// maybeValidateRequiredJSONPaths runs jsonDecodeValidateRequiredPaths when the live call succeeded (HTTP 200, no
+// transport error), checkRequired is enabled, cache is enabled, and the pipeline registered required paths. A
+// failed check sets err so the caller can fall back to the bridge cache when configured.
+func (t *BridgeTask) maybeValidateRequiredJSONPaths(err error, liveOK bool, cacheTTL Uint64Param, checkRequired BoolParam, responseBytes []byte) error {
+ if err != nil || !liveOK || cacheTTL == 0 || !bool(checkRequired) || len(t.requiredJSONPaths) == 0 {
+ return err
+ }
+ if verr := jsonDecodeValidateRequiredPaths(responseBytes, t.requiredJSONPaths); verr != nil {
+ return errors.Wrap(verr, "bridge response failed required JSON path check for downstream jsonparse")
+ }
+ return err
+}
+
+// resolveFailureOrCache handles a non-success HTTP outcome: it prefers the EA error from the body over the
+// transport error, increments error metrics, then either returns immediately (no cache TTL or cache miss) or
+// replaces the response body from the bridge cache. Non-nil early Result and RunInfo mean the caller must return
+// without continuing the success path.
+func (t *BridgeTask) resolveFailureOrCache(
+ ctx context.Context,
+ lggr logger.Logger,
+ url URLParam,
+ out bridgeHTTPOutcome,
+ cacheTTL Uint64Param,
+) (bridgeHTTPOutcome, *Result, *RunInfo) {
+ if out.err == nil && out.statusCode == http.StatusOK {
+ return out, nil, nil
+ }
+ if adapterErr := eautils.BestEffortExtractEAError(out.body); adapterErr != nil {
+ out.err = adapterErr
+ }
+
+ promBridgeErrors.WithLabelValues(t.Name).Inc()
+ if cacheTTL == 0 {
+ lggr.Debugw("Bridge task: request failed",
+ "response", string(out.body),
+ "url", url.String(),
+ "status_code", out.statusCode,
+ "error", out.err,
+ )
+ retry := RunInfo{IsRetryable: isRetryableHTTPError(out.statusCode, out.err)}
+ return out, &Result{Error: out.err}, &retry
+ }
+
+ //nolint:gosec // disable G115
+ cachedBytes, cacheErr := t.orm.GetCachedResponse(ctx, t.dotID, t.specId, time.Duration(cacheTTL)*time.Second)
+ if cacheErr != nil {
+ promBridgeCacheErrors.WithLabelValues(t.Name).Inc()
+ if !errors.Is(cacheErr, sql.ErrNoRows) {
+ lggr.Warnw("Bridge task: cache fallback failed",
+ "err", cacheErr.Error(),
+ "url", url.String(),
+ )
+ }
+ retry := RunInfo{IsRetryable: isRetryableHTTPError(out.statusCode, out.err)}
+ return out, &Result{Error: out.err}, &retry
+ }
+ promBridgeCacheHits.WithLabelValues(t.Name).Inc()
+ lggr.Debugw("Bridge task: request failed, falling back to cache",
+ "response", string(cachedBytes),
+ "url", url.String(),
+ )
+ out.body = cachedBytes
+ out.cachedResponse = true
+ return out, nil, nil
+}
+
func (bt *BridgeTelemetry) resolveStreamID(t *BridgeTask, vars Vars, lggr logger.Logger) {
if t.StreamID.Valid {
bt.StreamID = &t.StreamID.Uint32
@@ -331,3 +424,158 @@ func withRunInfo(request MapParam, meta MapParam) MapParam {
}
return output
}
+
+// getRequiredJSONPaths returns JSON path segments (split the same way as jsonparse) that strict
+// downstream jsonparse tasks require when they read this bridge task's string output directly.
+//
+// Limitations: does not follow merge/median/etc.; skips lax jsonparse, dynamic path/data
+// containing "$(", data="$(bridge.field)", or jsonparse whose data input is not this bridge
+// (by lowest output index or explicit $(bridgeID)).
+func (t *BridgeTask) getRequiredJSONPaths() [][]string {
+ if t == nil {
+ return nil
+ }
+ bridgeID := t.ID()
+ bridgeDot := t.DotID()
+
+ seen := make(map[string]struct{})
+ var out [][]string
+
+ for _, d := range t.GetDescendantTasks() {
+ jp, ok := d.(*JSONParseTask)
+ if !ok {
+ continue
+ }
+ if jsonParseLaxStatic(jp) {
+ continue
+ }
+ if strings.Contains(jp.Path, "$(") {
+ continue
+ }
+ dataTrim := strings.TrimSpace(jp.Data)
+ if strings.Contains(dataTrim, "$(") {
+ m := bridgeDataRootVarRegexp.FindStringSubmatch(dataTrim)
+ if len(m) != 2 || m[1] != bridgeDot {
+ continue
+ }
+ }
+ if !jsonParseDataReferencesBridge(jp, bridgeID, bridgeDot) {
+ continue
+ }
+ segs, ok := jsonParseStaticPathSegments(jp)
+ if !ok {
+ continue
+ }
+ key := strings.Join(segs, "\x00")
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, segs)
+ }
+ return out
+}
+
+// bridgeDataRootVarRegexp matches a data field that is only a single $(dotID)
+// reference with no nested keypath (dotID: [a-zA-Z0-9_]+).
+var bridgeDataRootVarRegexp = regexp.MustCompile(`^\$\(\s*([a-zA-Z0-9_]+)\s*\)$`)
+
+func jsonParseLaxStatic(j *JSONParseTask) bool {
+ trimmed := strings.TrimSpace(j.Lax)
+ if trimmed == "" {
+ return false
+ }
+ b, err := strconv.ParseBool(trimmed)
+ return err == nil && b
+}
+
+func jsonParseDataReferencesBridge(j *JSONParseTask, bridgeID int, bridgeDot string) bool {
+ dataTrim := strings.TrimSpace(j.Data)
+ if dataTrim == "" {
+ src, ok := jsonParseLowestIndexPropagatingInput(j)
+ return ok && src.ID() == bridgeID
+ }
+ m := bridgeDataRootVarRegexp.FindStringSubmatch(dataTrim)
+ return len(m) == 2 && m[1] == bridgeDot
+}
+
+func jsonParseLowestIndexPropagatingInput(j *JSONParseTask) (Task, bool) {
+ var (
+ found bool
+ minOutputIdx int32 = math.MaxInt32
+ src Task
+ )
+ for _, dep := range j.Inputs() {
+ if !dep.PropagateResult {
+ continue
+ }
+ idx := dep.InputTask.OutputIndex()
+ if !found || idx < minOutputIdx {
+ found = true
+ minOutputIdx = idx
+ src = dep.InputTask
+ }
+ }
+ return src, found
+}
+
+func jsonParseStaticPathSegments(j *JSONParseTask) ([]string, bool) {
+ if strings.TrimSpace(j.Path) == "" {
+ return nil, false
+ }
+ sep := strings.TrimSpace(j.Separator)
+ if sep == "" {
+ sep = ","
+ }
+ parts := strings.Split(j.Path, sep)
+ if len(parts) == 0 {
+ return nil, false
+ }
+ return parts, true
+}
+
+// jsonDecodeValidateRequiredPaths checks each path in the raw JSON body using
+// streaming lookup (no full value unmarshal). Missing keys, JSON null, JSON numbers
+// that are not valid for shopspring/decimal (including exponent/size limits), and the
+// string "NaN" are treated as failure so bridge cache fallback can apply.
+//
+// For each present value, parseRequiredValidValue must succeed: decimals and floats
+// via shopspring/decimal, integer literals (decimal or hex with optional sign) via
+// math/big so huge 0x… values validate and scientific notation is not mistaken for
+// hexadecimal.
+func jsonDecodeValidateRequiredPaths(body []byte, paths [][]string) error {
+ if len(paths) == 0 {
+ return nil
+ }
+ for _, path := range paths {
+ if len(path) == 0 {
+ continue
+ }
+
+ value, dataType, _, err := jsonparser.Get(body, path...)
+ if err != nil {
+ return errors.Wrapf(err, "required path %q", strings.Join(path, ","))
+ }
+
+ if dataType == jsonparser.Null {
+ return errors.Errorf("required path %q is null", strings.Join(path, ","))
+ }
+
+ if err := parseRequiredValidValue(value); err != nil {
+ return errors.Wrapf(err, "required path %q", strings.Join(path, ","))
+ }
+ }
+ return nil
+}
+
+// parseRequiredValidValue accepts values that can be parsed by shopspring/decimal or math/big.
+func parseRequiredValidValue(value []byte) error {
+ strValue := string(value)
+ if _, err := decimal.NewFromString(strValue); err == nil {
+ return nil
+ }
+ if _, ok := new(big.Int).SetString(strValue, 0); ok {
+ return nil
+ }
+ return errors.Errorf("invalid value: %s", string(value))
+}
diff --git a/core/services/pipeline/task.bridge_test.go b/core/services/pipeline/task.bridge_test.go
index 9d65f1bde40..bed32e0165b 100644
--- a/core/services/pipeline/task.bridge_test.go
+++ b/core/services/pipeline/task.bridge_test.go
@@ -143,20 +143,20 @@ func fakePriceResponder(t *testing.T, requestData map[string]any, result decimal
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var reqBody adapterRequest
payload, err := io.ReadAll(r.Body)
- require.NoError(t, err)
+ assert.NoError(t, err)
defer r.Body.Close()
err = json.Unmarshal(payload, &reqBody)
- require.NoError(t, err)
- require.Equal(t, expectedRequest.Data, reqBody.Data)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedRequest.Data, reqBody.Data)
w.Header().Set("Content-Type", "application/json")
- require.NoError(t, json.NewEncoder(w).Encode(response))
+ assert.NoError(t, json.NewEncoder(w).Encode(response))
if inputKey != "" {
m := utils.MustUnmarshalToMap(string(payload))
if expectedInput != nil {
- require.Equal(t, expectedInput, m[inputKey])
+ assert.Equal(t, expectedInput, m[inputKey])
} else {
- require.Nil(t, m[inputKey])
+ assert.Nil(t, m[inputKey])
}
}
})
@@ -175,28 +175,28 @@ func fakeIntermittentlyFailingPriceResponder(t *testing.T, requestData map[strin
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var reqBody adapterRequest
payload, err := io.ReadAll(r.Body)
- require.NoError(t, err)
+ assert.NoError(t, err)
defer r.Body.Close()
err = json.Unmarshal(payload, &reqBody)
- require.NoError(t, err)
- require.Equal(t, expectedRequest.Data, reqBody.Data)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedRequest.Data, reqBody.Data)
// require.Equal(t, float64(0), reqBody.Meta["id"])
if reqBody.Meta["shouldFail"].(bool) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
- require.NoError(t, json.NewEncoder(w).Encode(errors.New("EA failure")))
+ assert.NoError(t, json.NewEncoder(w).Encode(errors.New("EA failure")))
return
}
w.Header().Set("Content-Type", "application/json")
- require.NoError(t, json.NewEncoder(w).Encode(response))
+ assert.NoError(t, json.NewEncoder(w).Encode(response))
if inputKey != "" {
m := utils.MustUnmarshalToMap(string(payload))
if expectedInput != nil {
- require.Equal(t, expectedInput, m[inputKey])
+ assert.Equal(t, expectedInput, m[inputKey])
} else {
- require.Nil(t, m[inputKey])
+ assert.Nil(t, m[inputKey])
}
}
})
@@ -206,7 +206,7 @@ func fakeStringResponder(t *testing.T, s string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(s))
- require.NoError(t, err)
+ assert.NoError(t, err)
})
}
@@ -330,6 +330,112 @@ func TestBridgeTask_HandlesIntermittentFailure(t *testing.T) {
require.Equal(t, runInfo.IsRetryable, runInfo2.IsRetryable)
}
+func TestBridgeTask_CacheFallbackOnMissingRequiredJSONPath(t *testing.T) {
+ t.Parallel()
+
+ var callCount atomic.Int32
+ s1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.NoError(t, r.Body.Close())
+ w.Header().Set("Content-Type", "application/json")
+ if callCount.Add(1) == 1 {
+ resp := adapterResponse{Data: dataWithResult(t, decimal.NewFromInt(42))}
+ assert.NoError(t, json.NewEncoder(w).Encode(resp))
+ return
+ }
+ // HTTP 200 but missing data.result — should fall back to cache when required paths are set.
+ _, err := w.Write([]byte(`{"errorMessage":null,"error":null,"statusCode":null,"providerStatusCode":null,"data":{}}`))
+ assert.NoError(t, err)
+ }))
+ defer s1.Close()
+
+ db := pgtest.NewSqlxDB(t)
+ cfg := configtest.NewTestGeneralConfig(t)
+ feedURL, err := url.ParseRequestURI(s1.URL)
+ require.NoError(t, err)
+ orm := bridges.NewORM(db)
+ _, bridge := cltest.MustCreateBridge(t, db, cltest.BridgeOpts{URL: feedURL.String()})
+
+ task := pipeline.BridgeTask{
+ BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0),
+ Name: bridge.Name.String(),
+ RequestData: btcUSDPairing,
+ CacheTTL: "30s",
+ CheckRequired: "true",
+ }
+ pipeline.TestingSetBridgeRequiredJSONPaths(&task, [][]string{{"data", "result"}})
+ c := clhttptest.NewTestLocalOnlyHTTPClient()
+ trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns())
+ specID, err := trORM.CreateSpec(testutils.Context(t), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute))
+ require.NoError(t, err)
+ task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c)
+
+ ctx := testutils.Context(t)
+ result, runInfo := task.Run(ctx, logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil)
+ require.NoError(t, result.Error)
+ require.False(t, runInfo.IsRetryable)
+
+ result2, runInfo2 := task.Run(ctx, logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil)
+ require.NoError(t, result2.Error)
+ require.False(t, runInfo2.IsRetryable)
+ require.Equal(t, result.Value, result2.Value)
+
+ var parsed struct {
+ Data struct {
+ Result decimal.Decimal `json:"result"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(result2.Value.(string)), &parsed))
+ require.Equal(t, decimal.NewFromInt(42), parsed.Data.Result)
+}
+
+func TestBridgeTask_SkipsRequiredPathValidationWhenCheckRequiredFalse(t *testing.T) {
+ t.Parallel()
+
+ var callCount atomic.Int32
+ s1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.NoError(t, r.Body.Close())
+ w.Header().Set("Content-Type", "application/json")
+ if callCount.Add(1) == 1 {
+ resp := adapterResponse{Data: dataWithResult(t, decimal.NewFromInt(42))}
+ assert.NoError(t, json.NewEncoder(w).Encode(resp))
+ return
+ }
+ _, err := w.Write([]byte(`{"errorMessage":null,"error":null,"statusCode":null,"providerStatusCode":null,"data":{}}`))
+ assert.NoError(t, err)
+ }))
+ defer s1.Close()
+
+ db := pgtest.NewSqlxDB(t)
+ cfg := configtest.NewTestGeneralConfig(t)
+ feedURL, err := url.ParseRequestURI(s1.URL)
+ require.NoError(t, err)
+ orm := bridges.NewORM(db)
+ _, bridge := cltest.MustCreateBridge(t, db, cltest.BridgeOpts{URL: feedURL.String()})
+
+ task := pipeline.BridgeTask{
+ BaseTask: pipeline.NewBaseTask(0, "bridge", nil, nil, 0),
+ Name: bridge.Name.String(),
+ RequestData: btcUSDPairing,
+ CacheTTL: "30s",
+ // checkRequired omitted: do not run JSON path validation or cache fallback for it.
+ }
+ pipeline.TestingSetBridgeRequiredJSONPaths(&task, [][]string{{"data", "result"}})
+ c := clhttptest.NewTestLocalOnlyHTTPClient()
+ trORM := pipeline.NewORM(db, logger.TestLogger(t), cfg.JobPipeline().MaxSuccessfulRuns())
+ specID, err := trORM.CreateSpec(testutils.Context(t), pipeline.Pipeline{}, *sqlutil.NewInterval(5 * time.Minute))
+ require.NoError(t, err)
+ task.HelperSetDependencies(cfg.JobPipeline(), cfg.WebServer(), orm, specID, uuid.UUID{}, c)
+
+ ctx := testutils.Context(t)
+ _, runInfo := task.Run(ctx, logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil)
+ require.False(t, runInfo.IsRetryable)
+
+ result2, runInfo2 := task.Run(ctx, logger.TestLogger(t), pipeline.NewVarsFrom(nil), nil)
+ require.False(t, runInfo2.IsRetryable)
+ require.NoError(t, result2.Error)
+ require.Contains(t, result2.Value.(string), `"data":{}`)
+}
+
func TestBridgeTask_DoesNotReturnStaleResults(t *testing.T) {
t.Parallel()
@@ -435,17 +541,17 @@ func TestBridgeTask_AsyncJobPendingState(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var reqBody adapterRequest
payload, err := io.ReadAll(r.Body)
- require.NoError(t, err)
+ assert.NoError(t, err)
defer r.Body.Close()
err = json.Unmarshal(payload, &reqBody)
- require.NoError(t, err)
- require.Equal(t, fmt.Sprintf("%s/v2/resume/%v", cfg.WebServer().BridgeResponseURL(), id.String()), reqBody.ResponseURL)
+ assert.NoError(t, err)
+ assert.Equal(t, fmt.Sprintf("%s/v2/resume/%v", cfg.WebServer().BridgeResponseURL(), id.String()), reqBody.ResponseURL)
w.Header().Set("Content-Type", "application/json")
// w.Header().Set("X-Chainlink-Pending", "true")
response := map[string]any{"pending": true}
- require.NoError(t, json.NewEncoder(w).Encode(response))
+ assert.NoError(t, json.NewEncoder(w).Encode(response))
})
server := httptest.NewServer(handler)
@@ -680,11 +786,11 @@ func TestBridgeTask_Meta(t *testing.T) {
var req adapterRequest
body, _ := io.ReadAll(r.Body)
err := json.Unmarshal(body, &req)
- require.NoError(t, err)
- require.Equal(t, float64(10), req.Meta["latestAnswer"])
- require.Equal(t, float64(1616447984), req.Meta["updatedAt"])
+ assert.NoError(t, err)
+ assert.InEpsilon(t, float64(10), req.Meta["latestAnswer"], 0)
+ assert.InDelta(t, float64(1616447984), req.Meta["updatedAt"], 0)
w.Header().Set("Content-Type", "application/json")
- require.NoError(t, json.NewEncoder(w).Encode(empty))
+ assert.NoError(t, json.NewEncoder(w).Encode(empty))
httpCalled.Store(true)
})
@@ -798,7 +904,7 @@ func TestBridgeTask_ErrorMessage(t *testing.T) {
resp := &adapterResponse{}
resp.SetErrorMessage("could not hit data fetcher")
err := json.NewEncoder(w).Encode(resp)
- require.NoError(t, err)
+ assert.NoError(t, err)
})
server := httptest.NewServer(handler)
@@ -837,7 +943,7 @@ func TestBridgeTask_OnlyErrorMessage(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, err := w.Write([]byte(mustReadFile(t, "../../testdata/apiresponses/coinmarketcap.error.json")))
- require.NoError(t, err)
+ assert.NoError(t, err)
})
server := httptest.NewServer(handler)
@@ -927,7 +1033,7 @@ func TestBridgeTask_Headers(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`{"fooresponse": 1}`))
- require.NoError(t, err)
+ assert.NoError(t, err)
})
server := httptest.NewServer(handler)
@@ -1039,7 +1145,7 @@ func TestBridgeTask_AdapterResponseStatusFailure(t *testing.T) {
s1 := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := json.NewEncoder(w).Encode(testAdapterResponse)
- require.NoError(t, err)
+ assert.NoError(t, err)
}))
defer s1.Close()
@@ -1215,13 +1321,13 @@ ds [type=bridge name="adapter-error-bridge" timeout="50ms" requestData="{\"data\
bridge := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
b, herr := io.ReadAll(req.Body)
- require.NoError(t, herr)
- require.JSONEq(t, `{"data":{"from":"ETH","to":"USD"}}`, string(b))
+ assert.NoError(t, herr)
+ assert.JSONEq(t, `{"data":{"from":"ETH","to":"USD"}}`, string(b))
res.WriteHeader(http.StatusInternalServerError)
resp := `{"error": {"name":"AdapterLWBAError", "message": "bid ask violation detected"}}`
_, herr = res.Write([]byte(resp))
- require.NoError(t, herr)
+ assert.NoError(t, herr)
}))
t.Cleanup(bridge.Close)
u, _ := url.Parse(bridge.URL)
diff --git a/core/services/pipeline/task.jsonparse.go b/core/services/pipeline/task.jsonparse.go
index 3ba84ef047a..7382d3c6062 100644
--- a/core/services/pipeline/task.jsonparse.go
+++ b/core/services/pipeline/task.jsonparse.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
stderrors "errors"
- "math/big"
"strings"
"github.com/goccy/go-json"
@@ -68,46 +67,9 @@ func (t *JSONParseTask) Run(_ context.Context, _ logger.Logger, vars Vars, input
return Result{Error: err}, runInfo
}
- for _, part := range path {
- switch d := decoded.(type) {
- case map[string]any:
- var exists bool
- decoded, exists = d[part]
- if !exists && bool(lax) {
- decoded = nil
- break
- } else if !exists {
- return Result{Error: errors.Wrapf(ErrKeypathNotFound, `could not resolve path ["%v"] in %s`, strings.Join(path, `","`), data)}, runInfo
- }
-
- case []any:
- bigindex, ok := big.NewInt(0).SetString(part, 10)
- if !ok {
- return Result{Error: errors.Wrapf(ErrKeypathNotFound, "JSONParse task error: %v is not a valid array index", part)}, runInfo
- } else if !bigindex.IsInt64() {
- if bool(lax) {
- decoded = nil
- break
- }
- return Result{Error: errors.Wrapf(ErrKeypathNotFound, `could not resolve path ["%v"] in %s`, strings.Join(path, `","`), data)}, runInfo
- }
- index := int(bigindex.Int64())
- if index < 0 {
- index = len(d) + index
- }
-
- exists := index >= 0 && index < len(d)
- if !exists && bool(lax) {
- decoded = nil
- break
- } else if !exists {
- return Result{Error: errors.Wrapf(ErrKeypathNotFound, `could not resolve path ["%v"] in %s`, strings.Join(path, `","`), data)}, runInfo
- }
- decoded = d[index]
-
- default:
- return Result{Error: errors.Wrapf(ErrKeypathNotFound, `could not resolve path ["%v"] in %s`, strings.Join(path, `","`), data)}, runInfo
- }
+ decoded, err = traverseJSONPath(decoded, path, bool(lax))
+ if err != nil {
+ return Result{Error: errors.Wrapf(err, `could not resolve path ["%v"] in %s`, strings.Join(path, `","`), data)}, runInfo
}
decoded, err = jsonserializable.ReinterpretJSONNumbers(decoded)
diff --git a/core/web/resolver/testdata/config-empty-effective.toml b/core/web/resolver/testdata/config-empty-effective.toml
index a073a30ed45..84fef8a54a1 100644
--- a/core/web/resolver/testdata/config-empty-effective.toml
+++ b/core/web/resolver/testdata/config-empty-effective.toml
@@ -384,6 +384,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/web/resolver/testdata/config-full.toml b/core/web/resolver/testdata/config-full.toml
index 9fa49112c07..1d4b41e7ea1 100644
--- a/core/web/resolver/testdata/config-full.toml
+++ b/core/web/resolver/testdata/config-full.toml
@@ -401,6 +401,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/web/resolver/testdata/config-multi-chain-effective.toml b/core/web/resolver/testdata/config-multi-chain-effective.toml
index afe76d5ec60..f737a33ec54 100644
--- a/core/web/resolver/testdata/config-multi-chain-effective.toml
+++ b/core/web/resolver/testdata/config-multi-chain-effective.toml
@@ -384,6 +384,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/web/testdata/body/health.html b/core/web/testdata/body/health.html
index d36eae39cf5..d964b3bb929 100644
--- a/core/web/testdata/body/health.html
+++ b/core/web/testdata/body/health.html
@@ -93,6 +93,9 @@
JobSpawner
+
+ JobSpecReporter
+
LLOTransmissionReaper
diff --git a/core/web/testdata/body/health.json b/core/web/testdata/body/health.json
index df21a3bf01f..4fd58afbe16 100644
--- a/core/web/testdata/body/health.json
+++ b/core/web/testdata/body/health.json
@@ -171,6 +171,15 @@
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/core/web/testdata/body/health.txt b/core/web/testdata/body/health.txt
index b0a1b077851..ac6b5e44fb3 100644
--- a/core/web/testdata/body/health.txt
+++ b/core/web/testdata/body/health.txt
@@ -18,6 +18,7 @@ ok EVM.1399100.Txm.WrappedEvmEstimator
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
diff --git a/deployment/go.mod b/deployment/go.mod
index 26cef4bc61e..d975d0a3fab 100644
--- a/deployment/go.mod
+++ b/deployment/go.mod
@@ -36,7 +36,7 @@ require (
github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20260129135848-c86808ba5cb9
github.com/smartcontractkit/ccip-owner-contracts v0.1.0
github.com/smartcontractkit/chain-selectors v1.0.98
- github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d
+ github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1
github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260506144252-c100eabfda74
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc
@@ -430,13 +430,14 @@ require (
github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 // indirect
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c // indirect
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c // indirect
- github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c // indirect
+ github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83 // indirect
github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 // indirect
diff --git a/deployment/go.sum b/deployment/go.sum
index 6c4ae7e02d6..f8a5560d9e7 100644
--- a/deployment/go.sum
+++ b/deployment/go.sum
@@ -1379,8 +1379,8 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L
github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY=
github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww=
github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d h1:KMZmEzlZ+iknVFCuv+8o9iEKK52HXyeauIWbo+KhYv8=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb h1:6UjHnVanvb+6yJuefhyVlfv6YKFGMeZY5jv+a7Sexyo=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb/go.mod h1:FEm5fvIQe5O8Qdx6GvQcXsk7rDFpmYdIWXea5i4tpjw=
github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU=
github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08=
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8=
@@ -1423,8 +1423,8 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202604231355
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HcwehCao5k5C2NGuKJUVoX/AYtoH6njGFiV44dBOcY4=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c h1:0c+bCKo47vy/ItRtGa3S/vCpE5LRlgXpGnVKQX8TgjE=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c h1:1VVreRcffo3N3zF1JVtgS+YC4puuj3y0FU0Fta7L3U0=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 h1:v/rAtObo9zMpQDnGQ0seaSEqw60JUjowwcNw3DCpVY4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 h1:nXU0s4WAVU2cAR76Ke7h9z55NuEtRq1WvT4wVEs7jwk=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ=
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE=
@@ -1439,6 +1439,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/devenv/go.mod b/devenv/go.mod
index fd524205a82..aed099afcfe 100644
--- a/devenv/go.mod
+++ b/devenv/go.mod
@@ -27,7 +27,7 @@ require (
github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260504070828-3216bb63d886
github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20251211123524-f0c4fe7cfc0a
github.com/smartcontractkit/chainlink-protos/job-distributor v0.12.0
- github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.17
+ github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21
github.com/smartcontractkit/chainlink-testing-framework/framework/components/fake v0.14.9
github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5
github.com/smartcontractkit/chainlink-testing-framework/wasp v1.51.2
diff --git a/devenv/go.sum b/devenv/go.sum
index cc3f6cde67a..a3c3420edbd 100644
--- a/devenv/go.sum
+++ b/devenv/go.sum
@@ -1042,8 +1042,8 @@ github.com/smartcontractkit/chainlink-protos/job-distributor v0.12.0 h1:/bhoALRz
github.com/smartcontractkit/chainlink-protos/job-distributor v0.12.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.17 h1:QWXP12ewfRRVDbJcYJZBBCm5cP8Ds/KYpUc5VuAVYFk=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.17/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21 h1:lQs+r0+Jz9vFhmWPSo4bh0F1AePGIN7j++ex9cI4jA4=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/fake v0.14.9 h1:MVDx/Zl7qhikAx5vQgpTiyCkXw6sXgerUAU3WyJgWVY=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/fake v0.14.9/go.mod h1:1ZKcfw6mNKvM5GNy8AjeviL0tJVZoqhLZbmskcSG68k=
github.com/smartcontractkit/chainlink-testing-framework/lib/grafana v1.50.0 h1:VIxK8u0Jd0Q/VuhmsNm6Bls6Tb31H/sA3A/rbc5hnhg=
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index b7496871fb4..3fa35b26f09 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -2568,6 +2568,34 @@ IgnoreJoblessBridges = false # Default
```
IgnoreJoblessBridges skips bridges that have no associated jobs.
+## JobSpecReporter
+```toml
+[JobSpecReporter]
+Enabled = false # Default
+PollingInterval = "1h" # Default
+EnabledOCR2PluginTypes = ["median"] # Default
+```
+JobSpecReporter holds settings for the Job Spec Reporter service, which periodically emits job spec telemetry.
+
+### Enabled
+```toml
+Enabled = false # Default
+```
+Enabled enables the Job Spec Reporter service.
+
+### PollingInterval
+```toml
+PollingInterval = "1h" # Default
+```
+PollingInterval is how often to emit a heartbeat event for each tracked job.
+
+### EnabledOCR2PluginTypes
+```toml
+EnabledOCR2PluginTypes = ["median"] # Default
+```
+EnabledOCR2PluginTypes restricts OCR2 telemetry to jobs with these plugin types.
+An empty list disables all OCR2 telemetry. Use ["all"] to enable all OCR2 plugin types.
+
## CRE
```toml
[CRE]
diff --git a/go.md b/go.md
index 73ec2405836..f8e3d7438b1 100644
--- a/go.md
+++ b/go.md
@@ -28,7 +28,7 @@ flowchart LR
click ccip-owner-contracts href "https://github.com/smartcontractkit/ccip-owner-contracts"
chain-selectors
click chain-selectors href "https://github.com/smartcontractkit/chain-selectors"
- chainlink-aptos --> chainlink-common
+ chainlink-aptos --> chainlink-framework/metrics
click chainlink-aptos href "https://github.com/smartcontractkit/chainlink-aptos"
chainlink-automation --> chainlink-common
click chainlink-automation href "https://github.com/smartcontractkit/chainlink-automation"
@@ -125,6 +125,8 @@ flowchart LR
click chainlink-protos/chainlink-ccv/verifier href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/cre/go --> chain-selectors
click chainlink-protos/cre/go href "https://github.com/smartcontractkit/chainlink-protos"
+ chainlink-protos/data-feeds
+ click chainlink-protos/data-feeds href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/job-distributor
click chainlink-protos/job-distributor href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/linking-service/go
@@ -169,6 +171,7 @@ flowchart LR
chainlink/v2 --> chainlink-data-streams
chainlink/v2 --> chainlink-evm/contracts/cre/gobindings
chainlink/v2 --> chainlink-feeds
+ chainlink/v2 --> chainlink-protos/data-feeds
chainlink/v2 --> chainlink-protos/ring/go
chainlink/v2 --> cre-sdk-go/capabilities/networking/http
chainlink/v2 --> cre-sdk-go/capabilities/scheduler/cron
@@ -251,6 +254,7 @@ flowchart LR
chainlink-protos/chainlink-ccv/message-discovery
chainlink-protos/chainlink-ccv/verifier
chainlink-protos/cre/go
+ chainlink-protos/data-feeds
chainlink-protos/job-distributor
chainlink-protos/linking-service/go
chainlink-protos/node-platform
@@ -315,7 +319,7 @@ flowchart LR
click ccip-owner-contracts href "https://github.com/smartcontractkit/ccip-owner-contracts"
chain-selectors
click chain-selectors href "https://github.com/smartcontractkit/chain-selectors"
- chainlink-aptos --> chainlink-common
+ chainlink-aptos --> chainlink-framework/metrics
click chainlink-aptos href "https://github.com/smartcontractkit/chainlink-aptos"
chainlink-automation --> chainlink-common
click chainlink-automation href "https://github.com/smartcontractkit/chainlink-automation"
@@ -412,6 +416,8 @@ flowchart LR
click chainlink-protos/chainlink-ccv/verifier href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/cre/go --> chain-selectors
click chainlink-protos/cre/go href "https://github.com/smartcontractkit/chainlink-protos"
+ chainlink-protos/data-feeds
+ click chainlink-protos/data-feeds href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/job-distributor
click chainlink-protos/job-distributor href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/linking-service/go
@@ -581,6 +587,7 @@ flowchart LR
chainlink/v2 --> chainlink-data-streams
chainlink/v2 --> chainlink-evm/contracts/cre/gobindings
chainlink/v2 --> chainlink-feeds
+ chainlink/v2 --> chainlink-protos/data-feeds
chainlink/v2 --> chainlink-protos/ring/go
chainlink/v2 --> cre-sdk-go/capabilities/networking/http
chainlink/v2 --> cre-sdk-go/capabilities/scheduler/cron
@@ -709,6 +716,7 @@ flowchart LR
chainlink-protos/chainlink-ccv/message-discovery
chainlink-protos/chainlink-ccv/verifier
chainlink-protos/cre/go
+ chainlink-protos/data-feeds
chainlink-protos/job-distributor
chainlink-protos/linking-service/go
chainlink-protos/node-platform
diff --git a/go.mod b/go.mod
index 90ae700d30d..2dfe4c6b575 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
github.com/andybalholm/brotli v1.2.0
github.com/aptos-labs/aptos-go-sdk v1.12.1
github.com/avast/retry-go/v4 v4.7.0
+ github.com/buger/jsonparser v1.1.2
github.com/buraksezer/consistent v0.10.0
github.com/cespare/xxhash/v2 v2.3.0
github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.2
@@ -77,7 +78,7 @@ require (
github.com/shirou/gopsutil/v3 v3.24.3
github.com/shopspring/decimal v1.4.0
github.com/smartcontractkit/chain-selectors v1.0.98
- github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d
+ github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb
github.com/smartcontractkit/chainlink-automation v0.8.1
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1
github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260506144252-c100eabfda74
@@ -97,6 +98,7 @@ require (
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83
github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0
@@ -190,7 +192,6 @@ require (
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce // indirect
github.com/buger/goterm v1.0.4 // indirect
- github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytecodealliance/wasmtime-go/v28 v28.0.0 // indirect
github.com/bytedance/sonic v1.12.3 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
@@ -361,7 +362,7 @@ require (
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 // indirect
github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260408145530-22e2d05695cd // indirect
- github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c // indirect
+ github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
diff --git a/go.sum b/go.sum
index e3e4c40f715..32ecfa1c425 100644
--- a/go.sum
+++ b/go.sum
@@ -1228,8 +1228,8 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww=
github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d h1:KMZmEzlZ+iknVFCuv+8o9iEKK52HXyeauIWbo+KhYv8=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb h1:6UjHnVanvb+6yJuefhyVlfv6YKFGMeZY5jv+a7Sexyo=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb/go.mod h1:FEm5fvIQe5O8Qdx6GvQcXsk7rDFpmYdIWXea5i4tpjw=
github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU=
github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08=
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8=
@@ -1266,8 +1266,8 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202604231355
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HcwehCao5k5C2NGuKJUVoX/AYtoH6njGFiV44dBOcY4=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c h1:0c+bCKo47vy/ItRtGa3S/vCpE5LRlgXpGnVKQX8TgjE=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c h1:1VVreRcffo3N3zF1JVtgS+YC4puuj3y0FU0Fta7L3U0=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 h1:v/rAtObo9zMpQDnGQ0seaSEqw60JUjowwcNw3DCpVY4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 h1:nXU0s4WAVU2cAR76Ke7h9z55NuEtRq1WvT4wVEs7jwk=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ=
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE=
@@ -1282,6 +1282,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY=
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83 h1:yBSCSnJDMx7MCU0bGy6v1XK4CoP9Q7VZjjrIst4Q7rE=
diff --git a/integration-tests/go.mod b/integration-tests/go.mod
index 5654bf68980..9669256a2d4 100644
--- a/integration-tests/go.mod
+++ b/integration-tests/go.mod
@@ -24,7 +24,7 @@ require (
github.com/rs/zerolog v1.34.0
github.com/segmentio/ksuid v1.0.4
github.com/smartcontractkit/chain-selectors v1.0.98
- github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d
+ github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1
github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260506144252-c100eabfda74
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc
@@ -412,7 +412,7 @@ require (
github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 // indirect
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c // indirect
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c // indirect
- github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c // indirect
+ github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
@@ -420,6 +420,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83 // indirect
github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 // indirect
diff --git a/integration-tests/go.sum b/integration-tests/go.sum
index eba5a3397a2..cd3583a5597 100644
--- a/integration-tests/go.sum
+++ b/integration-tests/go.sum
@@ -1364,8 +1364,8 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L
github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY=
github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww=
github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d h1:KMZmEzlZ+iknVFCuv+8o9iEKK52HXyeauIWbo+KhYv8=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb h1:6UjHnVanvb+6yJuefhyVlfv6YKFGMeZY5jv+a7Sexyo=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb/go.mod h1:FEm5fvIQe5O8Qdx6GvQcXsk7rDFpmYdIWXea5i4tpjw=
github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU=
github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08=
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8=
@@ -1408,8 +1408,8 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202604231355
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HcwehCao5k5C2NGuKJUVoX/AYtoH6njGFiV44dBOcY4=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c h1:0c+bCKo47vy/ItRtGa3S/vCpE5LRlgXpGnVKQX8TgjE=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c h1:1VVreRcffo3N3zF1JVtgS+YC4puuj3y0FU0Fta7L3U0=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 h1:v/rAtObo9zMpQDnGQ0seaSEqw60JUjowwcNw3DCpVY4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 h1:nXU0s4WAVU2cAR76Ke7h9z55NuEtRq1WvT4wVEs7jwk=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ=
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE=
@@ -1424,6 +1424,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/integration-tests/load/go.mod b/integration-tests/load/go.mod
index 53d349e9864..d494d9b0106 100644
--- a/integration-tests/load/go.mod
+++ b/integration-tests/load/go.mod
@@ -15,7 +15,7 @@ require (
github.com/gagliardetto/solana-go v1.13.0
github.com/rs/zerolog v1.34.0
github.com/smartcontractkit/chain-selectors v1.0.98
- github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d
+ github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1
github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260506144252-c100eabfda74
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc
@@ -489,7 +489,7 @@ require (
github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 // indirect
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c // indirect
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c // indirect
- github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c // indirect
+ github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
@@ -497,6 +497,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 // indirect
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83 // indirect
diff --git a/integration-tests/load/go.sum b/integration-tests/load/go.sum
index 1573ccf8a5d..fcb54f74401 100644
--- a/integration-tests/load/go.sum
+++ b/integration-tests/load/go.sum
@@ -1632,8 +1632,8 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L
github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY=
github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww=
github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d h1:KMZmEzlZ+iknVFCuv+8o9iEKK52HXyeauIWbo+KhYv8=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb h1:6UjHnVanvb+6yJuefhyVlfv6YKFGMeZY5jv+a7Sexyo=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb/go.mod h1:FEm5fvIQe5O8Qdx6GvQcXsk7rDFpmYdIWXea5i4tpjw=
github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU=
github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08=
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8=
@@ -1676,8 +1676,8 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202604231355
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HcwehCao5k5C2NGuKJUVoX/AYtoH6njGFiV44dBOcY4=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c h1:0c+bCKo47vy/ItRtGa3S/vCpE5LRlgXpGnVKQX8TgjE=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c h1:1VVreRcffo3N3zF1JVtgS+YC4puuj3y0FU0Fta7L3U0=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 h1:v/rAtObo9zMpQDnGQ0seaSEqw60JUjowwcNw3DCpVY4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 h1:nXU0s4WAVU2cAR76Ke7h9z55NuEtRq1WvT4wVEs7jwk=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ=
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE=
@@ -1692,6 +1692,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/plugins/plugins.public.yaml b/plugins/plugins.public.yaml
index 7f1efd7ab32..4fa7a98df5c 100644
--- a/plugins/plugins.public.yaml
+++ b/plugins/plugins.public.yaml
@@ -10,7 +10,7 @@ defaults:
plugins:
aptos:
- moduleURI: "github.com/smartcontractkit/chainlink-aptos"
- gitRef: "v0.0.0-20260506112908-f0d993b5bd6d"
+ gitRef: "v0.0.0-20260507123701-77fc93b573bb"
installPath: "./cmd/chainlink-aptos"
sui:
diff --git a/system-tests/lib/go.mod b/system-tests/lib/go.mod
index 3adbcc8ea77..240982527b3 100644
--- a/system-tests/lib/go.mod
+++ b/system-tests/lib/go.mod
@@ -31,7 +31,7 @@ require (
github.com/scylladb/go-reflectx v1.0.1
github.com/sethvargo/go-retry v0.3.0
github.com/smartcontractkit/chain-selectors v1.0.98
- github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d
+ github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc
github.com/smartcontractkit/chainlink-common v0.11.2-0.20260506120607-7f10be016c89
github.com/smartcontractkit/chainlink-common/keystore v1.1.0
@@ -42,7 +42,7 @@ require (
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b
github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260323124644-faea187e6997
- github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19
+ github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3
github.com/smartcontractkit/chainlink-testing-framework/framework/components/dockercompose v0.1.22
github.com/smartcontractkit/chainlink-testing-framework/framework/components/fake v0.10.0
@@ -466,13 +466,14 @@ require (
github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 // indirect
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c // indirect
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c // indirect
- github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c // indirect
+ github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/committee-verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83 // indirect
github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 // indirect
github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd // indirect
diff --git a/system-tests/lib/go.sum b/system-tests/lib/go.sum
index 5122bb23c50..5ae0f471bda 100644
--- a/system-tests/lib/go.sum
+++ b/system-tests/lib/go.sum
@@ -1602,8 +1602,8 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L
github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY=
github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww=
github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d h1:KMZmEzlZ+iknVFCuv+8o9iEKK52HXyeauIWbo+KhYv8=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb h1:6UjHnVanvb+6yJuefhyVlfv6YKFGMeZY5jv+a7Sexyo=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb/go.mod h1:FEm5fvIQe5O8Qdx6GvQcXsk7rDFpmYdIWXea5i4tpjw=
github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU=
github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08=
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8=
@@ -1646,8 +1646,8 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202604231355
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HcwehCao5k5C2NGuKJUVoX/AYtoH6njGFiV44dBOcY4=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c h1:0c+bCKo47vy/ItRtGa3S/vCpE5LRlgXpGnVKQX8TgjE=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c h1:1VVreRcffo3N3zF1JVtgS+YC4puuj3y0FU0Fta7L3U0=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 h1:v/rAtObo9zMpQDnGQ0seaSEqw60JUjowwcNw3DCpVY4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 h1:nXU0s4WAVU2cAR76Ke7h9z55NuEtRq1WvT4wVEs7jwk=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ=
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE=
@@ -1662,6 +1662,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
@@ -1690,8 +1692,8 @@ github.com/smartcontractkit/chainlink-sui v0.0.0-20260429183453-39df0198aed8 h1:
github.com/smartcontractkit/chainlink-sui v0.0.0-20260429183453-39df0198aed8/go.mod h1:k1HSbHyPaQWPOj6lXDIAe04EuwbC5ge1nK+cpG2E8hE=
github.com/smartcontractkit/chainlink-sui/deployment v0.0.0-20260427132612-76b9f754a556 h1:6ocsoNPu3T0LsBiZ1tGZrjhKu8pGC1opUFz5KgHALSU=
github.com/smartcontractkit/chainlink-sui/deployment v0.0.0-20260427132612-76b9f754a556/go.mod h1:LkUo0a46JWaCsLY4SCV5ZOESudehe2RR62C1S46iOqw=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 h1:inTH0/PrEaVv4iLdGsdcrP/rX7KMrq/Roosr5nIA8io=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21 h1:lQs+r0+Jz9vFhmWPSo4bh0F1AePGIN7j++ex9cI4jA4=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3 h1:57wap/rDhBcw6+Ld7MqwQmXt2BTyVeNGvvPFDJcO8jQ=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3/go.mod h1:MjUJAyU+kLvLLPRPBs3X7zYadds5umZcjTLHjfNhpUc=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/dockercompose v0.1.22 h1:V+3clQPZ0/N8PJhJDkl00giFtsf9lv5XQJl0SCGWzcM=
diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod
index 9eb4eced4e1..12bc8e7235d 100644
--- a/system-tests/tests/go.mod
+++ b/system-tests/tests/go.mod
@@ -68,7 +68,7 @@ require (
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0
github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd
github.com/smartcontractkit/chainlink-protos/workflows/go v0.0.0-20260323124644-faea187e6997
- github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19
+ github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3
github.com/smartcontractkit/chainlink-testing-framework/framework/components/fake v0.10.0
github.com/smartcontractkit/chainlink-testing-framework/havoc v1.50.7
@@ -156,6 +156,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260430172634-acccf17ece83 // indirect
github.com/smartcontractkit/chainlink-solana v1.1.2-0.20260506142558-6d6e28042110 // indirect
github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260421131224-c46cbfe7bc6c // indirect
@@ -601,7 +602,7 @@ require (
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20260129135848-c86808ba5cb9 // indirect
github.com/smartcontractkit/ccip-owner-contracts v0.1.0 // indirect
- github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d
+ github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb
github.com/smartcontractkit/chainlink-automation v0.8.1 // indirect
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 // indirect
github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20251211140724-319861e514c4
@@ -609,7 +610,7 @@ require (
github.com/smartcontractkit/chainlink-feeds v0.1.2-0.20250227211209-7cd000095135 // indirect
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c // indirect
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c // indirect
- github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c // indirect
+ github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 // indirect
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
diff --git a/system-tests/tests/go.sum b/system-tests/tests/go.sum
index 8941d23fddd..6b9eabf2801 100644
--- a/system-tests/tests/go.sum
+++ b/system-tests/tests/go.sum
@@ -1815,8 +1815,8 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L
github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY=
github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww=
github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d h1:KMZmEzlZ+iknVFCuv+8o9iEKK52HXyeauIWbo+KhYv8=
-github.com/smartcontractkit/chainlink-aptos v0.0.0-20260506112908-f0d993b5bd6d/go.mod h1:zfE2R7887kiwXkGTHKPe5NBgwhFwIC3pnA2uAxrbvig=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb h1:6UjHnVanvb+6yJuefhyVlfv6YKFGMeZY5jv+a7Sexyo=
+github.com/smartcontractkit/chainlink-aptos v0.0.0-20260507123701-77fc93b573bb/go.mod h1:FEm5fvIQe5O8Qdx6GvQcXsk7rDFpmYdIWXea5i4tpjw=
github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU=
github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08=
github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260428205619-2db1389501a1 h1:p0nFrTYrOQzDhWYm6suaM5CoWiXV5NV7llHnp6/Kn/8=
@@ -1859,8 +1859,8 @@ github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-202604231355
github.com/smartcontractkit/chainlink-framework/capabilities v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HcwehCao5k5C2NGuKJUVoX/AYtoH6njGFiV44dBOcY4=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c h1:0c+bCKo47vy/ItRtGa3S/vCpE5LRlgXpGnVKQX8TgjE=
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:kGprqyjsz6qFNVszOQoHc24wfvCjyipNZFste/3zcbs=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c h1:1VVreRcffo3N3zF1JVtgS+YC4puuj3y0FU0Fta7L3U0=
-github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260423135514-5b1a7565a99c/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4 h1:v/rAtObo9zMpQDnGQ0seaSEqw60JUjowwcNw3DCpVY4=
+github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260505202410-b350dca113b4/go.mod h1:HG/aei0MgBOpsyRLexdKGtOUO8yjSJO3iUu0Uu8KBm4=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4 h1:nXU0s4WAVU2cAR76Ke7h9z55NuEtRq1WvT4wVEs7jwk=
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260505202410-b350dca113b4/go.mod h1:7ketk4ischPQW/JQgmyHz6zdzLUJv1VC29SiSgosydQ=
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 h1:GCzrxDWn3b7jFfEA+WiYRi8CKoegsayiDoJBCjYkneE=
@@ -1875,6 +1875,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 h1:5bxDnwI0wuPoC0H5H3H2n9CnQPb5iakR6UmAY4j8KUg=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36 h1:SG+wAsNyAcA6Kk19ljuxi3HK9Ll2lpHik8OKoY4x7A0=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260501174546-2e8846986b36/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
@@ -1903,8 +1905,8 @@ github.com/smartcontractkit/chainlink-sui v0.0.0-20260429183453-39df0198aed8 h1:
github.com/smartcontractkit/chainlink-sui v0.0.0-20260429183453-39df0198aed8/go.mod h1:k1HSbHyPaQWPOj6lXDIAe04EuwbC5ge1nK+cpG2E8hE=
github.com/smartcontractkit/chainlink-sui/deployment v0.0.0-20260427132612-76b9f754a556 h1:6ocsoNPu3T0LsBiZ1tGZrjhKu8pGC1opUFz5KgHALSU=
github.com/smartcontractkit/chainlink-sui/deployment v0.0.0-20260427132612-76b9f754a556/go.mod h1:LkUo0a46JWaCsLY4SCV5ZOESudehe2RR62C1S46iOqw=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 h1:inTH0/PrEaVv4iLdGsdcrP/rX7KMrq/Roosr5nIA8io=
-github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21 h1:lQs+r0+Jz9vFhmWPSo4bh0F1AePGIN7j++ex9cI4jA4=
+github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.21/go.mod h1:BALK9cj8sk12e15UF6uDhifHgIApa+6N11TcQfInEro=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3 h1:57wap/rDhBcw6+Ld7MqwQmXt2BTyVeNGvvPFDJcO8jQ=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/chiprouter v1.0.3/go.mod h1:MjUJAyU+kLvLLPRPBs3X7zYadds5umZcjTLHjfNhpUc=
github.com/smartcontractkit/chainlink-testing-framework/framework/components/dockercompose v0.1.22 h1:V+3clQPZ0/N8PJhJDkl00giFtsf9lv5XQJl0SCGWzcM=
diff --git a/testdata/scripts/config/merge_raw_configs.txtar b/testdata/scripts/config/merge_raw_configs.txtar
index 375ee9b1a9c..05999eb8ff7 100644
--- a/testdata/scripts/config/merge_raw_configs.txtar
+++ b/testdata/scripts/config/merge_raw_configs.txtar
@@ -531,6 +531,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/health/default.txtar b/testdata/scripts/health/default.txtar
index 076d109f206..a5dafbcd92b 100644
--- a/testdata/scripts/health/default.txtar
+++ b/testdata/scripts/health/default.txtar
@@ -37,6 +37,7 @@ ok CRE.DispatcherWrapper
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
@@ -107,6 +108,15 @@ ok WorkflowStore
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/testdata/scripts/health/multi-chain-loopp.txtar b/testdata/scripts/health/multi-chain-loopp.txtar
index e4d05c45eed..c265928b150 100644
--- a/testdata/scripts/health/multi-chain-loopp.txtar
+++ b/testdata/scripts/health/multi-chain-loopp.txtar
@@ -138,6 +138,7 @@ ok EVM.1.RelayerService.PluginRelayerClient.PluginEVM.Txm.WrappedEvmEstimator
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
@@ -500,6 +501,15 @@ ok WorkflowStore
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/testdata/scripts/health/multi-chain.txtar b/testdata/scripts/health/multi-chain.txtar
index f7417afe2cb..2c26bbe28b1 100644
--- a/testdata/scripts/health/multi-chain.txtar
+++ b/testdata/scripts/health/multi-chain.txtar
@@ -67,6 +67,7 @@ ok EVM.1.Txm.WrappedEvmEstimator
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
@@ -258,6 +259,15 @@ ok WorkflowStore
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/testdata/scripts/node/validate/default.txtar b/testdata/scripts/node/validate/default.txtar
index f3d2d8bd42d..aaf79909497 100644
--- a/testdata/scripts/node/validate/default.txtar
+++ b/testdata/scripts/node/validate/default.txtar
@@ -396,6 +396,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/defaults-override.txtar b/testdata/scripts/node/validate/defaults-override.txtar
index 1c1095d4f6e..819b6da4c1d 100644
--- a/testdata/scripts/node/validate/defaults-override.txtar
+++ b/testdata/scripts/node/validate/defaults-override.txtar
@@ -457,6 +457,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar
index 4f38567ac4d..bab7d2e8676 100644
--- a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar
+++ b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar
@@ -440,6 +440,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar
index 8ebd43333a2..e26feb4c031 100644
--- a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar
+++ b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar
@@ -440,6 +440,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/disk-based-logging.txtar b/testdata/scripts/node/validate/disk-based-logging.txtar
index 4f9631aff06..274bd6895ba 100644
--- a/testdata/scripts/node/validate/disk-based-logging.txtar
+++ b/testdata/scripts/node/validate/disk-based-logging.txtar
@@ -440,6 +440,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/fallback-override.txtar b/testdata/scripts/node/validate/fallback-override.txtar
index df43ea5f0b4..ee3727ad0f1 100644
--- a/testdata/scripts/node/validate/fallback-override.txtar
+++ b/testdata/scripts/node/validate/fallback-override.txtar
@@ -540,6 +540,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/invalid-ocr-p2p.txtar b/testdata/scripts/node/validate/invalid-ocr-p2p.txtar
index 714b54d7e75..c1e213fccd2 100644
--- a/testdata/scripts/node/validate/invalid-ocr-p2p.txtar
+++ b/testdata/scripts/node/validate/invalid-ocr-p2p.txtar
@@ -425,6 +425,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/invalid.txtar b/testdata/scripts/node/validate/invalid.txtar
index 28a2b2ff3e3..25e32cc2646 100644
--- a/testdata/scripts/node/validate/invalid.txtar
+++ b/testdata/scripts/node/validate/invalid.txtar
@@ -436,6 +436,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/valid.txtar b/testdata/scripts/node/validate/valid.txtar
index bf73f4924cf..2157d1ffd4e 100644
--- a/testdata/scripts/node/validate/valid.txtar
+++ b/testdata/scripts/node/validate/valid.txtar
@@ -437,6 +437,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/warnings.txtar b/testdata/scripts/node/validate/warnings.txtar
index 973da76176b..e4cc6e32476 100644
--- a/testdata/scripts/node/validate/warnings.txtar
+++ b/testdata/scripts/node/validate/warnings.txtar
@@ -419,6 +419,11 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876