Skip to content

Commit 72bf26a

Browse files
authored
Merge pull request #1979 from streamr-dev/FRONT-1977-node-version-check
[FRONT-1977] Node version check when joining sponsorship
2 parents 0347908 + 4bd2878 commit 72bf26a

File tree

3 files changed

+149
-12
lines changed

3 files changed

+149
-12
lines changed

src/modals/JoinSponsorshipModal.tsx

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import CopyIcon from '@atlaskit/icon/glyph/copy'
2-
import React, { useMemo, useState } from 'react'
2+
import React, { useMemo, useState, useEffect } from 'react'
33
import styled from 'styled-components'
44
import { toaster } from 'toasterhea'
55
import { Alert } from '~/components/Alert'
@@ -9,7 +9,7 @@ import { getSelfDelegationFraction } from '~/getters'
99
import { useConfigValueFromChain, useMediaQuery } from '~/hooks'
1010
import { useAllOperatorsForWalletQuery } from '~/hooks/operators'
1111
import { useSponsorshipTokenInfo } from '~/hooks/sponsorships'
12-
import { useInterceptHeartbeats } from '~/hooks/useInterceptHeartbeats'
12+
import { Heartbeat, useInterceptHeartbeats } from '~/hooks/useInterceptHeartbeats'
1313
import useOperatorLiveNodes from '~/hooks/useOperatorLiveNodes'
1414
import { SelectField2 } from '~/marketplace/components/SelectField2'
1515
import FormModal, {
@@ -35,6 +35,7 @@ import Label from '~/shared/components/Ui/Label'
3535
import useCopy from '~/shared/hooks/useCopy'
3636
import { useWalletAccount } from '~/shared/stores/wallet'
3737
import Toast from '~/shared/toasts/Toast'
38+
import { isNodeVersionGreaterThanOrEqualTo } from '~/shared/utils/nodeVersion'
3839
import { COLORS } from '~/shared/utils/styled'
3940
import { truncate } from '~/shared/utils/text'
4041
import { humanize } from '~/shared/utils/time'
@@ -55,6 +56,8 @@ interface Props extends Pick<FormModalProps, 'onReject'> {
5556

5657
const limitErrorToaster = toaster(Toast, Layer.Toast)
5758

59+
const requiredNodeVersion = '102.0.0'
60+
5861
function JoinSponsorshipModal({
5962
chainId,
6063
onResolve,
@@ -295,10 +298,6 @@ function JoinSponsorshipModal({
295298
</li>
296299
</PropList>
297300
</Section>
298-
<StyledAlert type="notice" title="Node version check">
299-
Node versions 100.2.3 and below are vulnerable to slashing. Ensure that
300-
all of your nodes are not running an outdated version.
301-
</StyledAlert>
302301
{isBelowSelfFundingLimit && (
303302
<StyledAlert type="error" title="Low self-funding">
304303
You cannot stake on Sponsorships because your Operator is below the
@@ -317,9 +316,10 @@ function JoinSponsorshipModal({
317316
</StyledAlert>
318317
)}
319318
{!isBelowSelfFundingLimit && !hasUndelegationQueue && (
320-
<LiveNodesCheck
319+
<LiveNodesAndVersionCheck
321320
liveNodesCountLoading={liveNodesCountLoading}
322321
liveNodesCount={liveNodesCount}
322+
heartbeats={heartbeats}
323323
/>
324324
)}
325325
{sponsorship.minimumStakingPeriodSeconds > 0 && (
@@ -343,21 +343,65 @@ function JoinSponsorshipModal({
343343
interface LiveNodesCheckProps {
344344
liveNodesCountLoading: boolean
345345
liveNodesCount: number
346+
heartbeats: Record<string, Heartbeat | undefined>
346347
}
347348

348-
function LiveNodesCheck({ liveNodesCountLoading, liveNodesCount }: LiveNodesCheckProps) {
349-
if (liveNodesCountLoading) {
349+
function LiveNodesAndVersionCheck({
350+
liveNodesCountLoading,
351+
liveNodesCount,
352+
heartbeats,
353+
}: LiveNodesCheckProps) {
354+
const [hasWaitedForHeartbeats, setHasWaitedForHeartbeats] = useState(false)
355+
356+
useEffect(() => {
357+
const timer = setTimeout(() => {
358+
setHasWaitedForHeartbeats(true)
359+
}, 6000) // Wait for 6 seconds to ensure heartbeats are available from ~all nodes
360+
361+
return () => clearTimeout(timer)
362+
}, [])
363+
364+
if (!hasWaitedForHeartbeats || liveNodesCountLoading) {
350365
return (
351366
<StyledAlert type="loading" title="Checking Streamr nodes">
352367
<span>
353-
In order to continue, you need to have one or more Streamr nodes
354-
running and correctly configured. You will be slashed if you stake
355-
without your nodes contributing resources to the stream.
368+
In order to continue, you need to have, 1 or more Streamr nodes
369+
running, and have them all be correctly configured. All of your nodes
370+
must be running version {requiredNodeVersion} or higher. You are
371+
vulnerable to slashing if you stake with misconfigured nodes.
356372
</span>
357373
</StyledAlert>
358374
)
359375
}
360376

377+
const nodeVersions = Object.values(heartbeats).map(
378+
(heartbeat) => heartbeat?.applicationVersion,
379+
)
380+
const areAllNodesRunningRequiredVersion = nodeVersions.every((version) => {
381+
// If version is undefined (old node) or null, treat it as not meeting the requirement
382+
if (!version) {
383+
return false
384+
}
385+
return isNodeVersionGreaterThanOrEqualTo(version, requiredNodeVersion)
386+
})
387+
if (!areAllNodesRunningRequiredVersion) {
388+
return (
389+
<StyledAlert type="error" title="Streamr nodes are outdated">
390+
<p>
391+
The minimum required version for all of your Streamr nodes is{' '}
392+
{requiredNodeVersion} or higher. Please update your nodes.
393+
</p>
394+
<a
395+
href={R.docs('/guides/how-to-update-your-streamr-node/')}
396+
target="_blank"
397+
rel="noreferrer noopener"
398+
>
399+
How to upgrade a Streamr node <LinkIcon name="externalLink" />
400+
</a>
401+
</StyledAlert>
402+
)
403+
}
404+
361405
if (liveNodesCount > 0) {
362406
return (
363407
<StyledAlert type="success" title="Streamr nodes detected">
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { isNodeVersionGreaterThanOrEqualTo } from './nodeVersion'
2+
3+
describe('nodeVersion utils', () => {
4+
describe('isNodeVersionGreaterThanOrEqualTo', () => {
5+
it('returns true when version is greater than required version', () => {
6+
expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', '12.0.0')).toBe(true)
7+
expect(isNodeVersionGreaterThanOrEqualTo('14.2.0', '14.1.0')).toBe(true)
8+
expect(isNodeVersionGreaterThanOrEqualTo('14.2.3', '14.2.2')).toBe(true)
9+
})
10+
11+
it('returns true when version equals required version', () => {
12+
expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', '14.0.0')).toBe(true)
13+
expect(isNodeVersionGreaterThanOrEqualTo('14.2.1', '14.2.1')).toBe(true)
14+
})
15+
16+
it('returns false when version is less than required version', () => {
17+
expect(isNodeVersionGreaterThanOrEqualTo('12.0.0', '14.0.0')).toBe(false)
18+
expect(isNodeVersionGreaterThanOrEqualTo('14.1.0', '14.2.0')).toBe(false)
19+
expect(isNodeVersionGreaterThanOrEqualTo('14.2.1', '14.2.2')).toBe(false)
20+
})
21+
22+
it('handles partial version numbers correctly', () => {
23+
expect(isNodeVersionGreaterThanOrEqualTo('14', '12')).toBe(true)
24+
expect(isNodeVersionGreaterThanOrEqualTo('14.2', '14.1')).toBe(true)
25+
expect(isNodeVersionGreaterThanOrEqualTo('14.2', '14.2')).toBe(true)
26+
})
27+
28+
it('handles invalid version numbers', () => {
29+
expect(isNodeVersionGreaterThanOrEqualTo('invalid', '14.0.0')).toBe(false)
30+
expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', 'invalid')).toBe(false)
31+
expect(isNodeVersionGreaterThanOrEqualTo('', '')).toBe(false)
32+
})
33+
34+
it('handles versions with different number of parts', () => {
35+
// Current version has more parts than required
36+
expect(isNodeVersionGreaterThanOrEqualTo('14.0.0', '14.0')).toBe(true)
37+
expect(isNodeVersionGreaterThanOrEqualTo('14.0.1', '14')).toBe(true)
38+
39+
// Required version has more parts than current
40+
expect(isNodeVersionGreaterThanOrEqualTo('14.0', '14.0.1')).toBe(false)
41+
expect(isNodeVersionGreaterThanOrEqualTo('14', '14.0.1')).toBe(false)
42+
})
43+
44+
it('handles leading zeros in version parts', () => {
45+
expect(isNodeVersionGreaterThanOrEqualTo('014.0.0', '14.0.0')).toBe(true)
46+
expect(isNodeVersionGreaterThanOrEqualTo('14.02', '14.2')).toBe(true)
47+
})
48+
})
49+
})

src/shared/utils/nodeVersion.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const normalizeVersion = (ver: string) => {
2+
if (!ver) return [NaN, NaN, NaN]
3+
4+
const parts = ver.split('.').map((part) => {
5+
const num = parseInt(part, 10)
6+
return isNaN(num) ? NaN : num
7+
})
8+
9+
// Check if any part is invalid first
10+
if (parts.some(isNaN)) return [NaN, NaN, NaN]
11+
12+
// Then pad missing parts with 0
13+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]
14+
}
15+
16+
export const isNodeVersionGreaterThanOrEqualTo = (
17+
version: string,
18+
requiredVersion: string,
19+
): boolean => {
20+
const [major, minor, patch] = normalizeVersion(version)
21+
const [requiredMajor, requiredMinor, requiredPatch] =
22+
normalizeVersion(requiredVersion)
23+
24+
// Handle invalid inputs
25+
if (
26+
isNaN(major) ||
27+
isNaN(minor) ||
28+
isNaN(patch) ||
29+
isNaN(requiredMajor) ||
30+
isNaN(requiredMinor) ||
31+
isNaN(requiredPatch)
32+
) {
33+
return false
34+
}
35+
36+
// Compare each part sequentially
37+
if (major !== requiredMajor) {
38+
return major > requiredMajor
39+
}
40+
if (minor !== requiredMinor) {
41+
return minor > requiredMinor
42+
}
43+
return patch >= requiredPatch
44+
}

0 commit comments

Comments
 (0)