diff --git a/pkg/version/compatibility.go b/pkg/version/compatibility.go new file mode 100644 index 00000000..6b121ece --- /dev/null +++ b/pkg/version/compatibility.go @@ -0,0 +1,44 @@ +package version + +import ( + "strings" + + "github.com/Masterminds/semver/v3" +) + +// IsCompatibleSupernodeVersion reports whether the supplied supernode version +// string satisfies the SDK gate (>= MinSupernodeVersion). +// +// Semantics: +// - A leading "v" is tolerated ("v2.5.0" parses as "2.5.0"). +// - Pre-release identifiers (-rc1, -beta, -alpha.1, +build) are accepted +// when the base version equals or exceeds the floor. The floor is +// compared as "MinSupernodeVersion-0" so that ANY pre-release of the +// target version (e.g. "2.5.0-rc1") is eligible. +// - Empty, unparseable, or otherwise malformed inputs are treated as +// INELIGIBLE (fail-closed). A supernode that cannot prove its version +// is presumed stale; the cost of a single retry against another node +// is strictly less than the cost of silently rotting an upload. +// +// The function never panics. +func IsCompatibleSupernodeVersion(reported string) bool { + reported = strings.TrimSpace(reported) + if reported == "" { + return false + } + + got, err := semver.NewVersion(reported) + if err != nil { + return false + } + + floor, err := semver.NewVersion(MinSupernodeVersion + "-0") + if err != nil { + // MinSupernodeVersion is a compile-time constant; an unparseable + // floor is a programmer error. Fail closed rather than letting + // every supernode through. + return false + } + + return !got.LessThan(floor) +} diff --git a/pkg/version/compatibility_test.go b/pkg/version/compatibility_test.go new file mode 100644 index 00000000..61c65893 --- /dev/null +++ b/pkg/version/compatibility_test.go @@ -0,0 +1,74 @@ +package version + +import ( + "testing" +) + +func TestIsCompatibleSupernodeVersion(t *testing.T) { + tests := []struct { + name string + reported string + want bool + }{ + // --- I1: floor and above are eligible --- + {"floor exact 2.5.0", "2.5.0", true}, + {"floor with v prefix", "v2.5.0", true}, + {"patch above floor", "2.5.1", true}, + {"minor above floor", "2.6.0", true}, + {"major above floor", "3.0.0", true}, + {"way above floor", "10.20.30", true}, + + // --- I4: 2.5.0 pre-releases are eligible per product decision --- + {"rc1 of floor", "2.5.0-rc1", true}, + {"rc2 of floor with v", "v2.5.0-rc2", true}, + {"rc with build meta", "2.5.0-rc2+build.5", true}, + {"beta of floor", "2.5.0-beta", true}, + {"alpha numeric", "2.5.0-alpha.1", true}, + // Boundary: semver -0 prerelease is the lowest possible 2.5.0-*. + {"floor with -0 prerelease boundary", "2.5.0-0", true}, + + // --- I4 negative: pre-release of an older base is NOT eligible. + // 2.4.99-rc1 < 2.5.0-0 in semver, so it must be rejected. + {"rc of pre-floor version", "2.4.99-rc1", false}, + {"beta of pre-floor version", "2.4.72-beta", false}, + + // --- I1 negative: older versions are not eligible --- + {"one patch below floor", "2.4.72", false}, + {"latest pre-2.5 hotfix", "2.4.72", false}, + {"pre-2.x", "1.99.99", false}, + {"zero version", "0.0.0", false}, + + // --- I2: malformed / empty inputs fail closed --- + {"empty string", "", false}, + {"only whitespace", " ", false}, + {"garbage", "not-a-version", false}, + // Masterminds/semver/v3 accepts partial inputs by padding zeros: + // "2.5" -> 2.5.0, "2" -> 2.0.0. The eligibility outcome is then + // driven purely by the resulting normalized version vs the floor. + {"incomplete major-minor parses to 2.5.0 (== floor)", "2.5", true}, + {"truncated to major parses to 2.0.0 (< floor)", "2", false}, + {"nonsense letters", "abc.def.ghi", false}, + {"negative numbers", "-1.0.0", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := IsCompatibleSupernodeVersion(tc.reported) + if got != tc.want { + t.Fatalf("IsCompatibleSupernodeVersion(%q) = %v, want %v", tc.reported, got, tc.want) + } + }) + } +} + +// TestMinSupernodeVersion_IsValidSemver guards against accidentally setting +// MinSupernodeVersion to a value that is unparseable, which would cause +// every IsCompatibleSupernodeVersion call to fail-closed and effectively +// brick the SDK. +func TestMinSupernodeVersion_IsValidSemver(t *testing.T) { + // IsCompatibleSupernodeVersion(MinSupernodeVersion) must be true: + // the constant itself is the floor and an exact match is eligible. + if !IsCompatibleSupernodeVersion(MinSupernodeVersion) { + t.Fatalf("MinSupernodeVersion=%q is not self-compatible; floor is unparseable or wrong", MinSupernodeVersion) + } +} diff --git a/pkg/version/min_supernode.go b/pkg/version/min_supernode.go new file mode 100644 index 00000000..55aa3352 --- /dev/null +++ b/pkg/version/min_supernode.go @@ -0,0 +1,32 @@ +// Package version centralises supernode version constants and the SDK-side +// compatibility gate used to refuse offloading work to supernodes that are +// older than the network's current minimum. +// +// Rationale (see PR description / Upgrade-Survival review for v1.12.0): +// the chain upgrades atomically at the halt height. The supernode fleet, +// however, upgrades asynchronously over hours-to-days, since operators +// control their own boxes. During the rollout window an SDK client that +// has already adopted post-upgrade behaviour (e.g. LEP-5 AvailabilityCommitment +// at register, requiring ChunkProof[] at finalize) can be paired with a +// supernode that lacks the matching code path. The resulting action stalls +// in PROCESSING until expiry: per-action data loss, no chain-wide impact, +// but visible to end users as "upload silently broken". +// +// The SDK-side gate is the single lever we have to prevent this: the SDK +// (this module) sits one import-layer above every caller (sdk-go, lumera- +// uploader, sn-api-server, partner clients). Refusing to pair a new client +// with an old supernode here means callers get a clear error and can retry +// against the next candidate. +package version + +// MinSupernodeVersion is the lowest supernode software version the SDK will +// accept as an upload target. +// +// IMPORTANT: This constant is the SINGLE SOURCE OF TRUTH for the version +// floor. Do not inline the literal anywhere else; import this constant. +// +// Pre-release suffixes (e.g. -rc1, -rc2, -beta, -beta+meta) on the same +// base version are intentionally treated as eligible — see +// IsCompatibleSupernodeVersion. The floor is "2.5.0-0" semantically, so +// "2.5.0-rc1" satisfies it while "2.4.99" does not. +const MinSupernodeVersion = "2.5.0" diff --git a/sdk/task/task.go b/sdk/task/task.go index 671ca689..3281f589 100644 --- a/sdk/task/task.go +++ b/sdk/task/task.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "strings" "sync" sdkmath "cosmossdk.io/math" txmod "github.com/LumeraProtocol/supernode/v2/pkg/lumera/modules/tx" + snversion "github.com/LumeraProtocol/supernode/v2/pkg/version" "github.com/LumeraProtocol/supernode/v2/sdk/adapters/lumera" "github.com/LumeraProtocol/supernode/v2/sdk/config" "github.com/LumeraProtocol/supernode/v2/sdk/event" @@ -210,6 +212,29 @@ func (t *BaseTask) filterEligibleSupernodesParallel(parent context.Context, sns return } + // (3) SDK-side version gate: refuse supernodes older than + // pkg/version.MinSupernodeVersion. This is the single lever + // available to the SDK to prevent a post-upgrade client from + // pairing with a pre-upgrade supernode during an asynchronous + // fleet rollout (e.g. v1.11.1 -> v1.12.0 + LEP-5). See + // pkg/version/min_supernode.go for rationale. The check is + // fail-closed: empty / unparseable / older versions are + // rejected. Pre-release tags on the floor version (e.g. + // 2.5.0-rc1) are intentionally accepted. + if !snversion.IsCompatibleSupernodeVersion(st.Version) { + reported := strings.TrimSpace(st.Version) + if reported == "" { + reported = "" + } + res.reason = fmt.Sprintf( + "supernode version %s is below SDK minimum %s", + reported, snversion.MinSupernodeVersion, + ) + _ = client.Close(context.Background()) + cancel() + return + } + res.ok = true res.client = client }() diff --git a/tests/scripts/setup-supernodes.sh b/tests/scripts/setup-supernodes.sh index da643f5a..ddf97aa2 100755 --- a/tests/scripts/setup-supernodes.sh +++ b/tests/scripts/setup-supernodes.sh @@ -47,14 +47,22 @@ setup_primary() { # Check if binary already exists if [ ! -f "$DATA_DIR/supernode" ]; then info "Building supernode binary from $SUPERNODE_SRC..." + # Inject a real semver into cmd.Version so the SDK-side version gate + # (pkg/version.MinSupernodeVersion) accepts the test binary. Default + # "dev" parses to 2.0.0 via supernode/supernode_metrics/metrics_collection.go::parseVersion, + # which is below the gate floor and causes filterEligibleSupernodesParallel + # to reject every candidate (`no eligible supernodes to register`). + # SUPERNODE_TEST_VERSION can be overridden by the caller (defaults to + # a non-rc 2.5.0 so the gate sees the harness as a current build). + : "${SUPERNODE_TEST_VERSION:=2.5.0-test}" CGO_ENABLED=1 \ GOOS=linux \ GOARCH=amd64 \ go build \ -trimpath \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/LumeraProtocol/supernode/v2/supernode/cmd.Version=${SUPERNODE_TEST_VERSION}" \ -o "$DATA_DIR/supernode" "$SUPERNODE_SRC" || error "Failed to build supernode binary" - success "Supernode binary built successfully" + success "Supernode binary built successfully (version=${SUPERNODE_TEST_VERSION})" else info "Supernode binary already exists, skipping build..." fi