Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions pkg/version/compatibility.go
Original file line number Diff line number Diff line change
@@ -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)
}
74 changes: 74 additions & 0 deletions pkg/version/compatibility_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
32 changes: 32 additions & 0 deletions pkg/version/min_supernode.go
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions sdk/task/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = "<unreported>"
}
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
}()
Expand Down
12 changes: 10 additions & 2 deletions tests/scripts/setup-supernodes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down