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
68 changes: 56 additions & 12 deletions src/modals/JoinSponsorshipModal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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, {
Expand All @@ -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'
Expand All @@ -55,6 +56,8 @@ interface Props extends Pick<FormModalProps, 'onReject'> {

const limitErrorToaster = toaster(Toast, Layer.Toast)

const requiredNodeVersion = '102.0.0'

function JoinSponsorshipModal({
chainId,
onResolve,
Expand Down Expand Up @@ -295,10 +298,6 @@ function JoinSponsorshipModal({
</li>
</PropList>
</Section>
<StyledAlert type="notice" title="Node version check">
Node versions 100.2.3 and below are vulnerable to slashing. Ensure that
all of your nodes are not running an outdated version.
</StyledAlert>
{isBelowSelfFundingLimit && (
<StyledAlert type="error" title="Low self-funding">
You cannot stake on Sponsorships because your Operator is below the
Expand All @@ -317,9 +316,10 @@ function JoinSponsorshipModal({
</StyledAlert>
)}
{!isBelowSelfFundingLimit && !hasUndelegationQueue && (
<LiveNodesCheck
<LiveNodesAndVersionCheck
liveNodesCountLoading={liveNodesCountLoading}
liveNodesCount={liveNodesCount}
heartbeats={heartbeats}
/>
)}
{sponsorship.minimumStakingPeriodSeconds > 0 && (
Expand All @@ -343,21 +343,65 @@ function JoinSponsorshipModal({
interface LiveNodesCheckProps {
liveNodesCountLoading: boolean
liveNodesCount: number
heartbeats: Record<string, Heartbeat | undefined>
}

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 (
<StyledAlert type="loading" title="Checking Streamr nodes">
<span>
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.
</span>
</StyledAlert>
)
}

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 (
<StyledAlert type="error" title="Streamr nodes are outdated">
<p>
The minimum required version for all of your Streamr nodes is{' '}
{requiredNodeVersion} or higher. Please update your nodes.
</p>
<a
href={R.docs('/guides/how-to-update-your-streamr-node/')}
target="_blank"
rel="noreferrer noopener"
>
How to upgrade a Streamr node <LinkIcon name="externalLink" />
</a>
</StyledAlert>
)
}

if (liveNodesCount > 0) {
return (
<StyledAlert type="success" title="Streamr nodes detected">
Expand Down
49 changes: 49 additions & 0 deletions src/shared/utils/nodeVersion.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
44 changes: 44 additions & 0 deletions src/shared/utils/nodeVersion.ts
Original file line number Diff line number Diff line change
@@ -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
}