diff --git a/src/modals/JoinSponsorshipModal.tsx b/src/modals/JoinSponsorshipModal.tsx index 962e9bebd6..06257d361f 100644 --- a/src/modals/JoinSponsorshipModal.tsx +++ b/src/modals/JoinSponsorshipModal.tsx @@ -1,5 +1,5 @@ import CopyIcon from '@atlaskit/icon/glyph/copy' -import React, { useMemo, useState } from 'react' +import React, { useMemo, useState, useEffect } from 'react' import styled from 'styled-components' import { toaster } from 'toasterhea' import { Alert } from '~/components/Alert' @@ -9,7 +9,7 @@ import { getSelfDelegationFraction } from '~/getters' import { useConfigValueFromChain, useMediaQuery } from '~/hooks' import { useAllOperatorsForWalletQuery } from '~/hooks/operators' import { useSponsorshipTokenInfo } from '~/hooks/sponsorships' -import { useInterceptHeartbeats } from '~/hooks/useInterceptHeartbeats' +import { Heartbeat, useInterceptHeartbeats } from '~/hooks/useInterceptHeartbeats' import useOperatorLiveNodes from '~/hooks/useOperatorLiveNodes' import { SelectField2 } from '~/marketplace/components/SelectField2' import FormModal, { @@ -35,6 +35,7 @@ import Label from '~/shared/components/Ui/Label' import useCopy from '~/shared/hooks/useCopy' import { useWalletAccount } from '~/shared/stores/wallet' import Toast from '~/shared/toasts/Toast' +import { isNodeVersionGreaterThanOrEqualTo } from '~/shared/utils/nodeVersion' import { COLORS } from '~/shared/utils/styled' import { truncate } from '~/shared/utils/text' import { humanize } from '~/shared/utils/time' @@ -55,6 +56,8 @@ interface Props extends Pick { const limitErrorToaster = toaster(Toast, Layer.Toast) +const requiredNodeVersion = '102.0.0' + function JoinSponsorshipModal({ chainId, onResolve, @@ -295,10 +298,6 @@ function JoinSponsorshipModal({ - - Node versions 100.2.3 and below are vulnerable to slashing. Ensure that - all of your nodes are not running an outdated version. - {isBelowSelfFundingLimit && ( You cannot stake on Sponsorships because your Operator is below the @@ -317,9 +316,10 @@ function JoinSponsorshipModal({ )} {!isBelowSelfFundingLimit && !hasUndelegationQueue && ( - )} {sponsorship.minimumStakingPeriodSeconds > 0 && ( @@ -343,21 +343,65 @@ function JoinSponsorshipModal({ interface LiveNodesCheckProps { liveNodesCountLoading: boolean liveNodesCount: number + heartbeats: Record } -function LiveNodesCheck({ liveNodesCountLoading, liveNodesCount }: LiveNodesCheckProps) { - if (liveNodesCountLoading) { +function LiveNodesAndVersionCheck({ + liveNodesCountLoading, + liveNodesCount, + heartbeats, +}: LiveNodesCheckProps) { + const [hasWaitedForHeartbeats, setHasWaitedForHeartbeats] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => { + setHasWaitedForHeartbeats(true) + }, 6000) // Wait for 6 seconds to ensure heartbeats are available from ~all nodes + + return () => clearTimeout(timer) + }, []) + + if (!hasWaitedForHeartbeats || liveNodesCountLoading) { return ( - In order to continue, you need to have one or more Streamr nodes - running and correctly configured. You will be slashed if you stake - without your nodes contributing resources to the stream. + In order to continue, you need to have, 1 or more Streamr nodes + running, and have them all be correctly configured. All of your nodes + must be running version {requiredNodeVersion} or higher. You are + vulnerable to slashing if you stake with misconfigured nodes. ) } + const nodeVersions = Object.values(heartbeats).map( + (heartbeat) => heartbeat?.applicationVersion, + ) + const areAllNodesRunningRequiredVersion = nodeVersions.every((version) => { + // If version is undefined (old node) or null, treat it as not meeting the requirement + if (!version) { + return false + } + return isNodeVersionGreaterThanOrEqualTo(version, requiredNodeVersion) + }) + if (!areAllNodesRunningRequiredVersion) { + return ( + +

+ The minimum required version for all of your Streamr nodes is{' '} + {requiredNodeVersion} or higher. Please update your nodes. +

+ + How to upgrade a Streamr node + +
+ ) + } + if (liveNodesCount > 0) { return ( diff --git a/src/shared/utils/nodeVersion.test.ts b/src/shared/utils/nodeVersion.test.ts new file mode 100644 index 0000000000..c3a33949b8 --- /dev/null +++ b/src/shared/utils/nodeVersion.test.ts @@ -0,0 +1,49 @@ +import { isNodeVersionGreaterThanOrEqualTo } from './nodeVersion' + +describe('nodeVersion utils', () => { + describe('isNodeVersionGreaterThanOrEqualTo', () => { + it('returns true when version is greater than required version', () => { + expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', '12.0.0')).toBe(true) + expect(isNodeVersionGreaterThanOrEqualTo('14.2.0', '14.1.0')).toBe(true) + expect(isNodeVersionGreaterThanOrEqualTo('14.2.3', '14.2.2')).toBe(true) + }) + + it('returns true when version equals required version', () => { + expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', '14.0.0')).toBe(true) + expect(isNodeVersionGreaterThanOrEqualTo('14.2.1', '14.2.1')).toBe(true) + }) + + it('returns false when version is less than required version', () => { + expect(isNodeVersionGreaterThanOrEqualTo('12.0.0', '14.0.0')).toBe(false) + expect(isNodeVersionGreaterThanOrEqualTo('14.1.0', '14.2.0')).toBe(false) + expect(isNodeVersionGreaterThanOrEqualTo('14.2.1', '14.2.2')).toBe(false) + }) + + it('handles partial version numbers correctly', () => { + expect(isNodeVersionGreaterThanOrEqualTo('14', '12')).toBe(true) + expect(isNodeVersionGreaterThanOrEqualTo('14.2', '14.1')).toBe(true) + expect(isNodeVersionGreaterThanOrEqualTo('14.2', '14.2')).toBe(true) + }) + + it('handles invalid version numbers', () => { + expect(isNodeVersionGreaterThanOrEqualTo('invalid', '14.0.0')).toBe(false) + expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', 'invalid')).toBe(false) + expect(isNodeVersionGreaterThanOrEqualTo('', '')).toBe(false) + }) + + it('handles versions with different number of parts', () => { + // Current version has more parts than required + expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', '14.0')).toBe(true) + expect(isNodeVersionGreaterThanOrEqualTo('14.0.1', '14')).toBe(true) + + // Required version has more parts than current + expect(isNodeVersionGreaterThanOrEqualTo('14.0', '14.0.1')).toBe(false) + expect(isNodeVersionGreaterThanOrEqualTo('14', '14.0.1')).toBe(false) + }) + + it('handles leading zeros in version parts', () => { + expect(isNodeVersionGreaterThanOrEqualTo('014.0.0', '14.0.0')).toBe(true) + expect(isNodeVersionGreaterThanOrEqualTo('14.02', '14.2')).toBe(true) + }) + }) +}) diff --git a/src/shared/utils/nodeVersion.ts b/src/shared/utils/nodeVersion.ts new file mode 100644 index 0000000000..c682678023 --- /dev/null +++ b/src/shared/utils/nodeVersion.ts @@ -0,0 +1,44 @@ +const normalizeVersion = (ver: string) => { + if (!ver) return [NaN, NaN, NaN] + + const parts = ver.split('.').map((part) => { + const num = parseInt(part, 10) + return isNaN(num) ? NaN : num + }) + + // Check if any part is invalid first + if (parts.some(isNaN)) return [NaN, NaN, NaN] + + // Then pad missing parts with 0 + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0] +} + +export const isNodeVersionGreaterThanOrEqualTo = ( + version: string, + requiredVersion: string, +): boolean => { + const [major, minor, patch] = normalizeVersion(version) + const [requiredMajor, requiredMinor, requiredPatch] = + normalizeVersion(requiredVersion) + + // Handle invalid inputs + if ( + isNaN(major) || + isNaN(minor) || + isNaN(patch) || + isNaN(requiredMajor) || + isNaN(requiredMinor) || + isNaN(requiredPatch) + ) { + return false + } + + // Compare each part sequentially + if (major !== requiredMajor) { + return major > requiredMajor + } + if (minor !== requiredMinor) { + return minor > requiredMinor + } + return patch >= requiredPatch +}