Repository: ObolNetwork/charon

Issue:
Keep a regex for minimum CL versions we support, and emit a startup/occasional warning log if its condition is not met
# 🎯 Problem to be solved

We want to keep people updating their CLs promptly. If they end up with unstashed changes in their template repo, they may be uploading charon only via the env and having their CL fall behind. We may discover incompatibilites between charon and older versions. 

# 🛠️ Proposed solution

We can emit a startup or e.g. hourly `warn` log that charon is not fully compatible with the connected CL. This should prompt people to keep their CL slightly more up to date. 

We could even consider a data structure that allows us to give particular warnings for particular regex matches, to e.g. warn that a node isn't deneb ready, not that it's incompatible with charon. Or the fact that MEV maybe won't work, or aggregations aren't supported. 


# 🧪 Tests
- [ ] Tested by new automated unit/integration/smoke tests
- [ ] Manually tested on core team/canary/test clusters
- [ ] Manually tested on local compose simnet

- Should handle checking many CLs, as many CLs can be under-versioned. 

<!-- Optional sections

# 👐 Additional acceptance criteria
List any additional acceptance criteria for this issue to be marked as closed.

# ❌ Out of Scope
If there is anything to highlight as out of scope for this issue, please outline it here.

-->


Hints from issue comments:
@xenowits: This is part of building the compatibility matrix, so that we know exactly which versions of CL we definitely support. The current ticket follows from there@KaloyanTanev: @OisinKyne is this still relevant? I'd say people should stick to our Kurtosis tested versions. If we want people to update their stack on every new version of EL/CL/VC/MEV/etc., we should first improve our Kurtosis testing to test latest charon with latest said version.

Solution patch:
diff --git a/app/eth2wrap/version.go b/app/eth2wrap/version.go
new file mode 100644
index 00000000..3d1f6e18
--- /dev/null
+++ b/app/eth2wrap/version.go
@@ -0,0 +1,99 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package eth2wrap
+
+import (
+	"context"
+	"regexp"
+
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/version"
+	"github.com/obolnetwork/charon/app/z"
+)
+
+var (
+	minLighthouseVersion, _ = version.Parse("v7.0.0")
+	minTekuVersion, _       = version.Parse("v25.4.1")
+	minLodestarVersion, _   = version.Parse("v1.29.0")
+	minNimbusVersion, _     = version.Parse("v25.4.0")
+	minPrysmVersion, _      = version.Parse("v6.0.0")
+	minGrandineVersion, _   = version.Parse("v1.1.0")
+
+	minimumBeaconNodeVersion = map[string]version.SemVer{
+		"Lighthouse": minLighthouseVersion,
+		"teku":       minTekuVersion,
+		"Lodestar":   minLodestarVersion,
+		"Nimbus":     minNimbusVersion,
+		"Prysm":      minPrysmVersion,
+		"Grandine":   minGrandineVersion,
+	}
+
+	incompatibleBeaconNodeVersion = map[string][]version.SemVer{}
+)
+
+type BeaconNodeVersionStatus int
+
+const (
+	VersionOK BeaconNodeVersionStatus = iota
+	VersionFormatError
+	VersionUnknownClient
+	VersionTooOld
+	VersionIncompatible
+)
+
+var versionExtractRegex = regexp.MustCompile(`^([^/]+)/v?([0-9]+\.[0-9]+\.[0-9]+)`)
+
+// CheckBeaconNodeVersionStatus checks the version of the beacon node client against the minimum required version and possible incompatible versions.
+// It returns the status of the version check as an enum, the current version, and the minimum required version.
+func checkBeaconNodeVersionStatus(bnVersion string) (beaconNodeVersionStatus BeaconNodeVersionStatus, clVer string, minVer string) {
+	matches := versionExtractRegex.FindStringSubmatch(bnVersion)
+	if len(matches) != 3 {
+		return VersionFormatError, "", ""
+	}
+
+	client := matches[1]
+	clientVersion, err := version.Parse("v" + matches[2])
+	if err != nil {
+		return VersionFormatError, "", ""
+	}
+
+	minVersion, ok := minimumBeaconNodeVersion[client]
+	if !ok {
+		return VersionUnknownClient, "", ""
+	}
+
+	if version.Compare(clientVersion, minVersion) == -1 {
+		return VersionTooOld, clientVersion.String(), minVersion.String()
+	}
+
+	for _, badVer := range incompatibleBeaconNodeVersion[client] {
+		if version.Compare(clientVersion, badVer) == 0 {
+			return VersionIncompatible, clientVersion.String(), ""
+		}
+	}
+
+	return VersionOK, clientVersion.String(), minVersion.String()
+}
+
+// CheckBeaconNodeVersion checks the version of the beacon node client and logs a warning if the version is below the minimum,
+// if its an incompatible version or if the client is not recognized.
+func CheckBeaconNodeVersion(ctx context.Context, bnVersion string) {
+	status, currentVersion, minVersion := checkBeaconNodeVersionStatus(bnVersion)
+
+	switch status {
+	case VersionFormatError:
+		log.Warn(ctx, "Failed to parse beacon node version string due to unexpected format",
+			nil, z.Str("input", bnVersion))
+	case VersionUnknownClient:
+		log.Warn(ctx, "Unknown beacon node client not in supported client list",
+			nil, z.Str("client", bnVersion))
+	case VersionTooOld:
+		log.Warn(ctx, "Beacon node client version is below the minimum supported version. Please upgrade your beacon node.",
+			nil, z.Str("client_version", currentVersion), z.Str("minimum_required", minVersion))
+	case VersionIncompatible:
+		log.Warn(ctx, "Beacon node client version is known to be incompatible. Please upgrade or downgrade your beacon node.",
+			nil, z.Str("client_version", currentVersion))
+	case VersionOK:
+		// Do nothing
+	}
+}
diff --git a/app/monitoringapi.go b/app/monitoringapi.go
index 2574cacb..1dd31ef6 100644
--- a/app/monitoringapi.go
+++ b/app/monitoringapi.go
@@ -243,6 +243,8 @@ func beaconNodeVersionMetric(ctx context.Context, eth2Cl eth2wrap.Client, clock
 
 		beaconNodeVersionGauge.Reset()
 		beaconNodeVersionGauge.WithLabelValues(version).Set(1)
+
+		eth2wrap.CheckBeaconNodeVersion(ctx, version)
 	}
 
 	go func() {
diff --git a/testutil/compose/static/lodestar/Dockerfile b/testutil/compose/static/lodestar/Dockerfile
index 7cbc52a4..a7e67e1e 100644
--- a/testutil/compose/static/lodestar/Dockerfile
+++ b/testutil/compose/static/lodestar/Dockerfile
@@ -1,4 +1,4 @@
-FROM chainsafe/lodestar:v1.29.0
+FROM chainsafe/lodestar:v1.30.0
 
 RUN apt-get update && apt-get install -y curl jq wget
 



Test patch:
diff --git a/app/eth2wrap/version_internal_test.go b/app/eth2wrap/version_internal_test.go
new file mode 100644
index 00000000..c9afdbbd
--- /dev/null
+++ b/app/eth2wrap/version_internal_test.go
@@ -0,0 +1,82 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package eth2wrap
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app/version"
+)
+
+func TestCheckBeaconNodeVersionStatus(t *testing.T) {
+	cases := []struct {
+		versionStr string
+		wantStatus BeaconNodeVersionStatus
+	}{
+		// Teku
+		{"teku/v25.4.1/linux-x86_64/-eclipseadoptium-openjdk64bitservervm-java-21", VersionOK},
+		{"teku/vUNKNOWN+g40561a9/linux-x86_64/-eclipseadoptium-openjdk64bitservervm-java-21", VersionFormatError},
+
+		// Lighthouse
+		{"Lighthouse/v7.0.3/x86_64-linux", VersionIncompatible},
+		{"Lighthouse/v7.0.1-e42406d/x86_64-linux", VersionOK},
+		{"Lighthouse/v7.0.0-54f7bc5/aarch64-linux", VersionOK},
+
+		// Lodestar
+		{"Lodestar/v1.29.0/8335180", VersionOK},
+		{"Lodestar/v1.30.0/1a34f98", VersionOK},
+
+		// Nimbus
+		{"Nimbus/v25.4.1-77cfa7-stateofus", VersionOK},
+		{"Nimbus/v25.5.0-d2f233-stateofus", VersionOK},
+		{"Nimbus/v25.4.0-c7e5ca-stateofus", VersionTooOld},
+
+		// Prysm
+		{"Prysm/v5.3.2 (linux amd64)", VersionTooOld},
+		{"Prysm/v6.0.2 (linux amd64)", VersionOK},
+		{"Prysm/v6.0.0 (linux amd64)", VersionOK},
+
+		// Grandine
+		{"Grandine/1.1.0-29cb5c1/x86_64-linux2025-05-19", VersionOK},
+
+		// Additional error cases
+		{"", VersionFormatError},
+		{"justastring", VersionFormatError},
+		{"/v7.0.0", VersionFormatError},
+		{"UnknownClient/v7.0.0", VersionUnknownClient},
+		{"Lighthouse/", VersionFormatError},
+		{"Lighthouse/vBAD", VersionFormatError},
+	}
+
+	// Redefine minimum versions here to be independent of future changes
+	minLighthouseVersion, _ = version.Parse("v7.0.0")
+	minTekuVersion, _ = version.Parse("v25.4.1")
+	minLodestarVersion, _ = version.Parse("v1.29.0")
+	minNimbusVersion, _ = version.Parse("v25.4.1")
+	minPrysmVersion, _ = version.Parse("v6.0.0")
+	minGrandineVersion, _ = version.Parse("v1.1.0")
+
+	minimumBeaconNodeVersion = map[string]version.SemVer{
+		"Lighthouse": minLighthouseVersion,
+		"teku":       minTekuVersion,
+		"Lodestar":   minLodestarVersion,
+		"Nimbus":     minNimbusVersion,
+		"Prysm":      minPrysmVersion,
+		"Grandine":   minGrandineVersion,
+	}
+
+	incompatibleLighthouseVersion, _ := version.Parse("v7.0.3")
+
+	incompatibleBeaconNodeVersion = map[string][]version.SemVer{
+		"Lighthouse": {incompatibleLighthouseVersion},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.versionStr, func(t *testing.T) {
+			status, _, _ := checkBeaconNodeVersionStatus(tc.versionStr)
+			require.Equal(t, tc.wantStatus, status)
+		})
+	}
+}