diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
deleted file mode 100644
index 9acf268e..00000000
--- a/.github/CODEOWNERS
+++ /dev/null
@@ -1,2 +0,0 @@
-* @lidofinance/community-staking
-.github @lidofinance/review-gh-workflows
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index f5cea03c..00000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,25 +0,0 @@
-## Description
-
-
-
-## Related Issue
-
-
-
-
-
-
-## How Has This Been Tested?
-
-
-
-
-
-## Checklist
-
-- [ ] Requires other services change
-- [ ] Affects to other services
-- [ ] Requires dependency update
-- [ ] Automated tests
-- [ ] Looks good on large screens
-- [ ] Looks good on mobile
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index 583decfd..00000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-version: 2
-updates:
- # Maintain dependencies for GitHub Actions
- - package-ecosystem: "github-actions"
- directory: "/"
- schedule:
- interval: "daily"
\ No newline at end of file
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
deleted file mode 100644
index d61e38fc..00000000
--- a/.github/workflows/checks.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-name: Tests and Checks
-
-on:
- pull_request:
-
-jobs:
- security:
- uses: lidofinance/linters/.github/workflows/security.yml@master
- permissions:
- security-events: write
- contents: read
-
- docker:
- uses: lidofinance/linters/.github/workflows/docker.yml@master
-
- todos:
- uses: lidofinance/linters/.github/workflows/todos.yml@master
-
- actions:
- uses: lidofinance/linters/.github/workflows/actions.yml@master
diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml
deleted file mode 100644
index dfe72ff2..00000000
--- a/.github/workflows/ci-dev.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: CI Dev
-
-on:
- workflow_dispatch:
- push:
- branches:
- - develop
- paths-ignore:
- - ".github/**"
-
-permissions: {}
-
-jobs:
- # test:
- # ...
-
- deploy:
- runs-on: ubuntu-latest
- # needs: test
- name: Build and deploy
- steps:
- - name: Testnet deploy
- uses: lidofinance/dispatch-workflow@v1
- env:
- APP_ID: ${{ secrets.APP_ID }}
- APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
- TARGET_REPO: "lidofinance/infra-mainnet"
- TARGET_WORKFLOW: "deploy_testnet_csm_widget.yaml"
- TARGET: "develop"
diff --git a/.github/workflows/ci-preview-demolish.yml b/.github/workflows/ci-preview-demolish.yml
deleted file mode 100644
index 0056c15f..00000000
--- a/.github/workflows/ci-preview-demolish.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: CI Preview stand demolish
-
-on:
- workflow_dispatch:
- pull_request:
- types:
- [converted_to_draft, closed]
- branches-ignore:
- - main
-
-permissions: {}
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- name: Build and deploy
- steps:
- - name: Preview stand deploying
- uses: lidofinance/dispatch-workflow@v1
- env:
- APP_ID: ${{ secrets.APP_ID }}
- APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
- TARGET_REPO: "lidofinance/infra-mainnet"
- TARGET: ${{ github.head_ref }}
- TARGET_WORKFLOW: "preview_stand_demolish.yaml"
- INPUTS_REPO_NAME: ${{ github.repository }}
- INPUTS_PR_ID: ${{ github.event.pull_request.number }}
diff --git a/.github/workflows/ci-preview-deploy.yml b/.github/workflows/ci-preview-deploy.yml
deleted file mode 100644
index f2bd30ce..00000000
--- a/.github/workflows/ci-preview-deploy.yml
+++ /dev/null
@@ -1,68 +0,0 @@
-name: CI Preview stand deploy
-
-on:
- workflow_dispatch:
- inputs:
- inventory:
- description: inventory to be used for preview stand deploying
- default: testnet
- required: false
- type: choice
- options:
- - staging
- - testnet
-
- pull_request:
- types:
- [opened, synchronize, reopened, ready_for_review]
- branches-ignore:
- - main
-
-permissions:
- contents: read
- pull-requests: read
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- if: ${{ github.event.pull_request.draft == false }}
- name: Build and deploy
- outputs:
- stand_url: ${{ steps.stand.outputs.url }}
- steps:
- - uses: lidofinance/gh-find-current-pr@v1
- id: pr
-
- - name: Set ref
- id: ref
- run: echo "short_ref=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
-
- - name: Preview stand deploying
- uses: lidofinance/dispatch-workflow@v1
- env:
- APP_ID: ${{ secrets.APP_ID }}
- APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
- TARGET_REPO: "lidofinance/infra-mainnet"
- TARGET: ${{ github.head_ref || steps.ref.outputs.short_ref }}
- TARGET_WORKFLOW: "preview_stand_deploy.yaml"
- INPUTS_REPO_NAME: ${{ github.repository }}
- INPUTS_PR_ID: ${{ github.event.pull_request.number || steps.pr.outputs.number }}
- INPUTS_INVENTORY: "${{ inputs.inventory || 'testnet' }}"
-
- - name: Define repo short name
- run: echo "short_name=$(echo ${{ github.repository }} | cut -d "/" -f 2)" >> $GITHUB_OUTPUT
- id: repo
-
- - name: Define branch hash
- run: echo "hash=$(echo "$HEAD_REF" | shasum -a 256 | cut -c -10)" >> $GITHUB_OUTPUT
- id: branch
- env:
- HEAD_REF: ${{ github.head_ref || steps.ref.outputs.short_ref }}
-
- - name: Extract stand url
- if: always()
- run: echo "url=https://$SHORT_NAME-$BRANCH_HASH.branch-preview.org" >> $GITHUB_OUTPUT
- id: stand
- env:
- SHORT_NAME: ${{ steps.repo.outputs.short_name }}
- BRANCH_HASH: ${{ steps.branch.outputs.hash }}
diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml
deleted file mode 100644
index b4800633..00000000
--- a/.github/workflows/ci-prod.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-name: CI Build prod image
-
-on:
- release:
- types: [released]
-
-permissions: {}
-
-jobs:
- # test:
- # ...
-
- deploy:
- runs-on: ubuntu-latest
- # needs: test
- name: Build and deploy
- steps:
- - name: Build prod image
- uses: lidofinance/dispatch-workflow@v1
- env:
- APP_ID: ${{ secrets.APP_ID }}
- APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
- TARGET_REPO: "lidofinance/infra-mainnet"
- TAG: '${{ github.event.release.tag_name }}'
- TARGET_WORKFLOW: "build_mainnet_csm_widget.yaml"
diff --git a/.github/workflows/ci-staging.yml b/.github/workflows/ci-staging.yml
deleted file mode 100644
index 4a85830a..00000000
--- a/.github/workflows/ci-staging.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-name: CI Staging
-
-on:
- workflow_dispatch:
- push:
- branches:
- - main
- paths-ignore:
- - ".github/**"
-
-permissions: {}
-
-jobs:
- deploy:
- if: ${{ github.repository != 'lidofinance/lido-frontend-template' }}
- runs-on: ubuntu-latest
- name: Build and deploy
- steps:
- - name: Staging deploy
- uses: lidofinance/dispatch-workflow@v1
- env:
- APP_ID: ${{ secrets.APP_ID }}
- APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
- TARGET_REPO: "lidofinance/infra-mainnet"
- TARGET_WORKFLOW: "deploy_staging_mainnet_csm_widget.yaml"
- TARGET: "main"
diff --git a/.github/workflows/dappnode-release.yml b/.github/workflows/dappnode-release.yml
new file mode 100644
index 00000000..58d7c7e2
--- /dev/null
+++ b/.github/workflows/dappnode-release.yml
@@ -0,0 +1,85 @@
+name: Release and Publish Docker Image
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to release (optional, defaults to patch increment)'
+ required: false
+ default: ''
+
+jobs:
+ release:
+ name: Release and Publish Docker Image
+ runs-on: ubuntu-latest
+
+ steps:
+ # Checkout the repository
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # Fetch all tags
+ - name: Fetch tags
+ run: git fetch --tags
+
+ # Determine the next version
+ - name: Determine release version
+ id: determine_version
+ run: |
+ # Get the input version
+ INPUT_VERSION="${{ github.event.inputs.version }}"
+
+ # Find the latest tag
+ LATEST_TAG=$(git tag --sort=-v:refname | head -n 1)
+
+ # If an input version is provided, use it
+ if [[ -n "$INPUT_VERSION" ]]; then
+ NEW_VERSION="$INPUT_VERSION"
+ else
+ # Increment the patch version by default
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_TAG"
+ NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
+ fi
+
+ # If no releases exist, default to 0.1.0
+ if [[ "$LATEST_TAG" == "0.0.0" ]]; then
+ NEW_VERSION="0.1.0"
+ fi
+
+ echo "New version: $NEW_VERSION"
+ echo "::set-output name=version::$NEW_VERSION"
+
+ # Create a GitHub release
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.determine_version.outputs.version }}
+ name: Release ${{ steps.determine_version.outputs.version }}
+ body: |
+ Automatically generated release ${{ steps.determine_version.outputs.version }}.
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Push the new tag to the repository
+ - name: Push new tag
+ run: |
+ git tag ${{ steps.determine_version.outputs.version }}
+ git push origin ${{ steps.determine_version.outputs.version }}
+
+ # Log in to GitHub Docker Registry
+ - name: Log in to GitHub Docker Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Build and push Docker image
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ steps.determine_version.outputs.version }}
diff --git a/.github/workflows/prepare-release-draft.yml b/.github/workflows/prepare-release-draft.yml
deleted file mode 100644
index 8ea14dd0..00000000
--- a/.github/workflows/prepare-release-draft.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-name: Prepare release draft
-on:
- push:
- branches:
- - main
-
-permissions:
- contents: write
-
-jobs:
- prepare-release-draft:
- uses: lidofinance/actions/.github/workflows/prepare-release-draft.yml@main
- with:
- target: main
diff --git a/Dockerfile b/Dockerfile
index 9b66c081..4311ef82 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,22 +16,25 @@ RUN rm -rf /app/public/runtime && mkdir /app/public/runtime && chown node /app/p
FROM node:20-alpine as base
ARG BASE_PATH=""
-ARG SUPPORTED_CHAINS="1"
-ARG DEFAULT_CHAIN="1"
+# SUPPORTED_CHAINS and DEFAULT_CHAIN will be set in dappnode Lido CSM generic repository
+#ARG DEFAULT_CHAIN="1"
ENV NEXT_TELEMETRY_DISABLED=1 \
- BASE_PATH=$BASE_PATH \
- SUPPORTED_CHAINS=$SUPPORTED_CHAINS \
- DEFAULT_CHAIN=$DEFAULT_CHAIN
+ BASE_PATH=$BASE_PATH
+#SUPPORTED_CHAINS=$SUPPORTED_CHAINS \
+#DEFAULT_CHAIN=$DEFAULT_CHAIN
WORKDIR /app
RUN apk add --no-cache curl=~8
COPY --from=build /app /app
USER node
-EXPOSE 3000
+#EXPOSE 3000
+# DAPPNODE
+EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s \
- CMD curl -f http://localhost:3000/api/health || exit 1
+ CMD curl -f http://localhost/api/health || exit 1
+#CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["yarn", "start"]
diff --git a/assets/icons/down-arrow.svg b/assets/icons/down-arrow.svg
new file mode 100644
index 00000000..4ba9c987
--- /dev/null
+++ b/assets/icons/down-arrow.svg
@@ -0,0 +1,6 @@
+
+
+
\ No newline at end of file
diff --git a/assets/icons/eye-off.svg b/assets/icons/eye-off.svg
new file mode 100644
index 00000000..a390ff06
--- /dev/null
+++ b/assets/icons/eye-off.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/icons/eye-on.svg b/assets/icons/eye-on.svg
new file mode 100644
index 00000000..844a3ce4
--- /dev/null
+++ b/assets/icons/eye-on.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/consts/csm-constants.ts b/consts/csm-constants.ts
index 744483ae..3dc4a709 100644
--- a/consts/csm-constants.ts
+++ b/consts/csm-constants.ts
@@ -21,6 +21,8 @@ type CsmConstants = {
stakingModuleId: number;
withdrawalCredentials: Address;
retentionPeriodMins: number;
+ lidoFeeRecipient: Address; // DAPPNODE
+ reportTimestamp: number; // DAPPNODE, the timestamp from an epoch where a report was distributed
};
export const CONSTANTS_BY_NETWORK: Partial> = {
@@ -39,6 +41,8 @@ export const CONSTANTS_BY_NETWORK: Partial> = {
stakingModuleId: 3,
withdrawalCredentials: '0xB9D7934878B5FB9610B3fE8A5e441e8fad7E293f',
retentionPeriodMins: 80_640, // 8 weeks
+ lidoFeeRecipient: '0x388C818CA8B9251b393131C08a736A67ccB19297', // DAPPNODE
+ reportTimestamp: 1732282199, // DAPPNODE, epoch 326714
},
[CHAINS.Holesky]: {
contracts: {
@@ -55,6 +59,8 @@ export const CONSTANTS_BY_NETWORK: Partial> = {
stakingModuleId: 4,
withdrawalCredentials: '0xF0179dEC45a37423EAD4FaD5fCb136197872EAd9',
retentionPeriodMins: 80_640, // 8 weeks
+ lidoFeeRecipient: '0xE73a3602b99f1f913e72F8bdcBC235e206794Ac8', // DAPPNODE
+ reportTimestamp: 1734371136, // DAPPNODE, epoch 100179
},
};
diff --git a/consts/urls.ts b/consts/urls.ts
index bae3eb6d..4818c793 100644
--- a/consts/urls.ts
+++ b/consts/urls.ts
@@ -13,10 +13,12 @@ export const PATH = {
BOND_CLAIM: '/bond/claim',
BOND_ADD: '/bond/add',
BOND_UNLOCK: '/bond/unlock',
+ PERFORMANCE: '/performance', // DAPPNODE
ROLES: '/roles',
ROLES_REWARDS: '/roles/reward-address',
ROLES_MANAGER: '/roles/manager-address',
ROLES_INBOX: '/roles/inbox',
+ NOTIFICATIONS: '/notifications', // DAPPNODE
STEALING: '/stealing',
STEALING_REPORT: '/stealing/report',
STEALING_CANCEL: '/stealing/cancel',
diff --git a/dappnode/components/modal.tsx b/dappnode/components/modal.tsx
new file mode 100644
index 00000000..ab49ba92
--- /dev/null
+++ b/dappnode/components/modal.tsx
@@ -0,0 +1,85 @@
+import React, { ReactNode } from 'react';
+import styled from 'styled-components';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ children: ReactNode;
+ closeOnOverlayClick?: boolean;
+}
+
+const Overlay = styled.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+`;
+
+const Card = styled.div`
+ position: relative;
+ background: var(--lido-color-foreground);
+ color: var(--lido-color-textSecondary);
+ padding: 1.5rem;
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ z-index: 1001;
+ max-width: 33rem;
+`;
+
+const CloseButton = styled.button`
+ position: absolute;
+ top: 0.5rem; /* Adjust to fine-tune positioning */
+ right: 0.5rem; /* Adjust to fine-tune positioning */
+ background: none;
+ border: none;
+ color: aliceblue;
+ font-weight: bold;
+ cursor: pointer;
+ font-size: 1.25rem;
+`;
+
+const ChildreWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ text-align: left;
+ > h3 {
+ font-size: 18px;
+ }
+ > div,
+ > p {
+ font-size: 14px;
+ }
+`;
+
+export const LinkWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+`;
+
+const Modal: React.FC = ({
+ isOpen,
+ onClose,
+ children,
+ closeOnOverlayClick = true,
+}) => {
+ if (!isOpen) return null;
+
+ return (
+
+ e.stopPropagation()}>
+ ×
+ {children}
+
+
+ );
+};
+
+export default Modal;
diff --git a/dappnode/components/text-wrappers.ts b/dappnode/components/text-wrappers.ts
new file mode 100644
index 00000000..89c199a6
--- /dev/null
+++ b/dappnode/components/text-wrappers.ts
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+export const ErrorWrapper = styled.div`
+ color: var(--lido-color-error);
+ font-weight: 600;
+`;
+
+export const WarningWrapper = styled.div`
+ color: var(--lido-color-warning);
+ font-weight: 600;
+`;
+
+export const SuccessWrapper = styled.div`
+ color: var(--lido-color-success);
+ font-weight: 600;
+`;
diff --git a/dappnode/fallbacks/ec-no-logs-page.tsx b/dappnode/fallbacks/ec-no-logs-page.tsx
new file mode 100644
index 00000000..91bf1316
--- /dev/null
+++ b/dappnode/fallbacks/ec-no-logs-page.tsx
@@ -0,0 +1,29 @@
+import { Link } from '@lidofinance/lido-ui';
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
+import { FC } from 'react';
+import { Layout } from 'shared/layout';
+import { WelcomeSection } from './welcome-section-component';
+import { ErrorWrapper } from 'dappnode/components/text-wrappers';
+import { Note } from 'shared/components';
+
+export const ECNoLogsPage: FC = () => {
+ const { stakersUiUrl } = useDappnodeUrls();
+ return (
+
+
+
+ Execution Client not storing logs
+
+ Your Execution Client is not configured to store log receipts.
+
+ Please either enable log receipt storage on your current client or
+ switch to an Execution Client that supports this feature by default.
+
+ Switch your Execution client{' '}
+
+ Clients like Besu, Geth, and Nethermind store log receipts by default.
+
+ {' '}
+
+ );
+};
diff --git a/dappnode/fallbacks/ec-not-installed-page.tsx b/dappnode/fallbacks/ec-not-installed-page.tsx
new file mode 100644
index 00000000..3df0db9f
--- /dev/null
+++ b/dappnode/fallbacks/ec-not-installed-page.tsx
@@ -0,0 +1,27 @@
+import { Link } from '@lidofinance/lido-ui';
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
+import { FC } from 'react';
+import { Layout } from 'shared/layout';
+import { WelcomeSection } from './welcome-section-component';
+import { ErrorWrapper } from 'dappnode/components/text-wrappers';
+
+export const ECNotInstalledPage: FC = () => {
+ const { stakersUiUrl } = useDappnodeUrls();
+
+ return (
+
+
+
+ Execution client is not installed
+
+
+ This UI requires an execution client synced to function properly.{' '}
+
+ Please select and Execution client and wait until it's synced
+ before continuing.
+
+ Set up your node
+
+
+ );
+};
diff --git a/dappnode/fallbacks/ec-scanning-events.tsx b/dappnode/fallbacks/ec-scanning-events.tsx
new file mode 100644
index 00000000..85d6801d
--- /dev/null
+++ b/dappnode/fallbacks/ec-scanning-events.tsx
@@ -0,0 +1,23 @@
+import { FC } from 'react';
+import { Layout } from 'shared/layout';
+import { WelcomeSection } from './welcome-section-component';
+import { WarningWrapper } from 'dappnode/components/text-wrappers';
+import { LoaderBanner } from 'shared/navigate/splash/loader-banner';
+
+export const ECScanningPage: FC = () => {
+ return (
+
+
+
+ Execution client scanning blocks
+
+ To retrieve data, this UI needs to scan the blockchain events.
+
+ This may take up to 10 minutes during your first login, depending on
+ your execution client.
+
+
+
+
+ );
+};
diff --git a/dappnode/fallbacks/ec-syncing-page.tsx b/dappnode/fallbacks/ec-syncing-page.tsx
new file mode 100644
index 00000000..ac9589b3
--- /dev/null
+++ b/dappnode/fallbacks/ec-syncing-page.tsx
@@ -0,0 +1,23 @@
+import { FC } from 'react';
+import { Layout } from 'shared/layout';
+import { WelcomeSection } from './welcome-section-component';
+import { WarningWrapper } from 'dappnode/components/text-wrappers';
+import { LoaderBanner } from 'shared/navigate/splash/loader-banner';
+
+export const ECSyncingPage: FC = () => {
+ return (
+
+
+
+ Execution client is syncing
+
+
+ This UI requires an execution client synced to function properly.{' '}
+
+ Please, Wait until it's synced before continuing.
+
+
+
+
+ );
+};
diff --git a/dappnode/fallbacks/welcome-section-component.tsx b/dappnode/fallbacks/welcome-section-component.tsx
new file mode 100644
index 00000000..529ebb5b
--- /dev/null
+++ b/dappnode/fallbacks/welcome-section-component.tsx
@@ -0,0 +1,31 @@
+import {
+ BlockStyled,
+ CSMLogo,
+ Header,
+ Heading,
+} from 'features/welcome/welcome-section/styles';
+import { FC, PropsWithChildren } from 'react';
+import styled from 'styled-components';
+
+const ContentWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spaceMap.md}px;
+
+ text-align: center;
+ color: var(--lido-color-text);
+ font-size: ${({ theme }) => theme.fontSizesMap.xs}px;
+ line-height: ${({ theme }) => theme.fontSizesMap.xl}px;
+`;
+
+export const WelcomeSection: FC = ({ children }) => {
+ return (
+
+
+
+
+
+ {children}
+
+ );
+};
diff --git a/dappnode/hooks/use-brain-keystore-api.ts b/dappnode/hooks/use-brain-keystore-api.ts
new file mode 100644
index 00000000..bf12443d
--- /dev/null
+++ b/dappnode/hooks/use-brain-keystore-api.ts
@@ -0,0 +1,45 @@
+import { useState, useEffect, useCallback } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+
+const useApiBrain = (interval = 60000) => {
+ const { brainKeysUrl } = useDappnodeUrls();
+
+ const [pubkeys, setPubkeys] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState();
+
+ const fetchPubkeys = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(brainKeysUrl, { method: 'GET' });
+
+ if (response.ok) {
+ const data = await response.json();
+ setPubkeys(data.lido || []);
+ setError(undefined);
+ } else {
+ console.error('Error fetching brain keys:', response);
+ setError(response);
+ }
+ } catch (e) {
+ console.error(e);
+ setError(e);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [brainKeysUrl]);
+
+ useEffect(() => {
+ void fetchPubkeys();
+
+ const intervalId = setInterval(() => {
+ void fetchPubkeys();
+ }, interval);
+
+ return () => clearInterval(intervalId);
+ }, [fetchPubkeys, interval]);
+
+ return { pubkeys, isLoading, error };
+};
+
+export default useApiBrain;
diff --git a/dappnode/hooks/use-brain-launchpad-api.ts b/dappnode/hooks/use-brain-launchpad-api.ts
new file mode 100644
index 00000000..e4661cbf
--- /dev/null
+++ b/dappnode/hooks/use-brain-launchpad-api.ts
@@ -0,0 +1,72 @@
+import { useState, useCallback } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+import { CONSTANTS_BY_NETWORK } from 'consts/csm-constants';
+import { useChainId } from 'wagmi';
+
+const useBrainLaunchpadApi = () => {
+ const { brainLaunchpadUrl } = useDappnodeUrls();
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSuccess, setIsSuccess] = useState(false);
+ const [error, setError] = useState(undefined);
+
+ const chainId = useChainId() as keyof typeof CONSTANTS_BY_NETWORK;
+
+ const lidoFeeRecipient =
+ CONSTANTS_BY_NETWORK[chainId]?.lidoFeeRecipient ?? '';
+
+ const submitKeystores = useCallback(
+ async ({
+ keystores,
+ password,
+ }: {
+ keystores: object[];
+ password: string;
+ }) => {
+ setIsLoading(true);
+ setIsSuccess(false);
+ setError(undefined);
+
+ // Push same pass and tag for every entry in keystores
+ const passwords: string[] = [];
+ const tags: string[] = [];
+ const feeRecipients: string[] = [];
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ for (const keystore of keystores) {
+ passwords.push(password);
+ tags.push('lido');
+ feeRecipients.push(lidoFeeRecipient);
+ }
+
+ const stringifiedKeystores = keystores.map((keystore) =>
+ JSON.stringify(keystore),
+ );
+
+ const keystoresData = {
+ keystores: stringifiedKeystores,
+ passwords,
+ tags,
+ feeRecipients,
+ };
+
+ try {
+ const response = await fetch(`${brainLaunchpadUrl}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json; charset=UTF-8' },
+ body: JSON.stringify(keystoresData),
+ });
+
+ if (response.ok) setIsSuccess(true);
+ } catch (error) {
+ console.error(error);
+ setError(error as Error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [brainLaunchpadUrl, lidoFeeRecipient],
+ );
+
+ return { submitKeystores, isLoading, isSuccess, error };
+};
+
+export default useBrainLaunchpadApi;
diff --git a/dappnode/hooks/use-check-deposit-keys.ts b/dappnode/hooks/use-check-deposit-keys.ts
new file mode 100644
index 00000000..74515706
--- /dev/null
+++ b/dappnode/hooks/use-check-deposit-keys.ts
@@ -0,0 +1,51 @@
+import { useEffect, useState } from 'react';
+import { DepositData } from 'types';
+import useApiBrain from './use-brain-keystore-api';
+
+const useCheckImportedDepositKeys = (depositData: DepositData[]) => {
+ const { pubkeys: brainKeys, isLoading: brainKeysLoading } = useApiBrain();
+
+ const [keysLoading, setKeysLoading] = useState(false);
+
+ const [keysInDeposit, setKeysInDeposit] = useState([]);
+ const [missingKeys, setMissingKeys] = useState([]);
+
+ useEffect(() => {
+ const keys = [];
+ for (const key of depositData) {
+ keys.push(key.pubkey);
+ }
+ setKeysInDeposit(keys);
+ }, [depositData]);
+
+ useEffect(() => {
+ if (brainKeysLoading) {
+ setKeysLoading(true);
+ } else {
+ setKeysLoading(false);
+ }
+ }, [brainKeysLoading]);
+
+ // Filtering lido keys by status and brain imported
+ useEffect(() => {
+ const missingEntries: string[] = [];
+ if (brainKeys) {
+ const formattedBrainKeys = brainKeys.map((key) => key.toLowerCase());
+
+ for (const key of keysInDeposit) {
+ if (!formattedBrainKeys.includes('0x' + key)) {
+ missingEntries.push(key);
+ }
+ }
+ } else {
+ for (const key of keysInDeposit) {
+ missingEntries.push(key);
+ }
+ }
+ setMissingKeys(missingEntries);
+ }, [keysInDeposit, brainKeys]);
+
+ return { missingKeys, keysLoading };
+};
+
+export default useCheckImportedDepositKeys;
diff --git a/dappnode/hooks/use-dappnode-urls.ts b/dappnode/hooks/use-dappnode-urls.ts
new file mode 100644
index 00000000..a934a6aa
--- /dev/null
+++ b/dappnode/hooks/use-dappnode-urls.ts
@@ -0,0 +1,120 @@
+import { CHAINS } from '@lido-sdk/constants';
+import getConfig from 'next/config';
+
+interface DappnodeUrls {
+ brainUrl: string;
+ brainKeysUrl: string;
+ brainLaunchpadUrl: string;
+ signerUrl: string;
+ sentinelUrl: string;
+ stakersUiUrl: string;
+ backendUrl: string;
+ ECApiUrl: string;
+ CCVersionApiUrl: string;
+ CCStatusApiUrl: string;
+ keysStatusUrl: string;
+ installerTabUrl: string;
+ MEVApiUrl: string;
+ MEVPackageConfig: string;
+}
+
+const useDappnodeUrls = () => {
+ // Rely on runtime config to get the chainId and avoid nullish values when wallet is not connected from chainId
+ const { publicRuntimeConfig } = getConfig();
+
+ const urlsByChain: Partial> = {
+ [CHAINS.Mainnet]: {
+ brainUrl: 'http://brain.web3signer.dappnode',
+ brainKeysUrl: '/api/brain-keys-mainnet',
+ brainLaunchpadUrl: '/api/brain-launchpad-mainnet',
+ signerUrl: 'http://web3signer.web3signer.dappnode',
+ sentinelUrl: 'https://t.me/CSMSentinel_bot',
+ stakersUiUrl: 'http://my.dappnode/stakers/ethereum',
+ backendUrl: 'http://lido-events.lido-csm-mainnet.dappnode:8080',
+ ECApiUrl:
+ publicRuntimeConfig.rpcUrls_1 ||
+ 'http://execution.mainnet.dncore.dappnode:8545',
+ CCVersionApiUrl: '/api/consensus-version-mainnet',
+ CCStatusApiUrl: '/api/consensus-status-mainnet',
+ keysStatusUrl: '/api/keys-status-mainnet',
+ installerTabUrl:
+ 'http://my.dappnode/installer/dnp/lido-csm-holesky.dnp.dappnode.eth',
+ MEVApiUrl: '/api/mev-status-mainnet',
+ MEVPackageConfig:
+ 'http://my.dappnode/packages/my/mev-boost.dnp.dappnode.eth/config',
+ },
+ [CHAINS.Holesky]: {
+ brainUrl: 'http://brain.web3signer-holesky.dappnode',
+ brainKeysUrl: '/api/brain-keys-holesky',
+ brainLaunchpadUrl: '/api/brain-launchpad-holesky',
+ signerUrl: 'http://web3signer.web3signer-holesky.dappnode',
+ sentinelUrl: 'https://t.me/CSMSentinelHolesky_bot',
+ stakersUiUrl: 'http://my.dappnode/stakers/holesky',
+ backendUrl: 'http://lido-events.lido-csm-holesky.dappnode:8080',
+ ECApiUrl:
+ publicRuntimeConfig.rpcUrls_17000 ||
+ 'http://execution.holesky.dncore.dappnode:8545',
+ CCVersionApiUrl: '/api/consensus-version-holesky',
+ CCStatusApiUrl: '/api/consensus-status-holesky',
+ keysStatusUrl: '/api/keys-status-holesky',
+ installerTabUrl:
+ 'http://my.dappnode/installer/dnp/lido-csm-mainnet.dnp.dappnode.eth',
+ MEVApiUrl: '/api/mev-status-holesky',
+ MEVPackageConfig:
+ 'http://my.dappnode/packages/my/mev-boost-holesky.dnp.dappnode.eth/config',
+ },
+ };
+
+ const brainUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.brainUrl || '';
+ const brainKeysUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.brainKeysUrl || '';
+ const brainLaunchpadUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]
+ ?.brainLaunchpadUrl || '';
+ const signerUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.signerUrl || '';
+ const sentinelUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.sentinelUrl || '';
+ const stakersUiUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.stakersUiUrl || '';
+ const backendUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.backendUrl || '';
+ const ECApiUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.ECApiUrl || '';
+ const CCVersionApiUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.CCVersionApiUrl ||
+ '';
+ const CCStatusApiUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.CCStatusApiUrl ||
+ '';
+ const keysStatusUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.keysStatusUrl ||
+ '';
+ const installerTabUrl = (isMainnet: boolean) =>
+ urlsByChain[isMainnet ? 1 : 17000]?.installerTabUrl;
+ const MEVApiUrl =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.MEVApiUrl || '';
+ const MEVPackageConfig =
+ urlsByChain[publicRuntimeConfig.defaultChain as CHAINS]?.MEVPackageConfig ||
+ '';
+
+ return {
+ brainUrl,
+ brainKeysUrl,
+ brainLaunchpadUrl,
+ signerUrl,
+ sentinelUrl,
+ stakersUiUrl,
+ backendUrl,
+ ECApiUrl,
+ CCVersionApiUrl,
+ CCStatusApiUrl,
+ keysStatusUrl,
+ installerTabUrl,
+ MEVApiUrl,
+ MEVPackageConfig,
+ };
+};
+
+export default useDappnodeUrls;
diff --git a/dappnode/hooks/use-ec-sanity-check.ts b/dappnode/hooks/use-ec-sanity-check.ts
new file mode 100644
index 00000000..5be07524
--- /dev/null
+++ b/dappnode/hooks/use-ec-sanity-check.ts
@@ -0,0 +1,98 @@
+import { CHAINS } from '@lido-sdk/constants';
+import getConfig from 'next/config';
+import { useEffect, useMemo, useState } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+
+export const useECSanityCheck = () => {
+ const [isInstalled, setIsInstalled] = useState(true); // Use default true to avoid frontend flickering with IsInstalled component
+ const [isSynced, setIsSynced] = useState(true); // Use default true to avoid frontend flickering with IsSynced component
+ const [hasLogs, setHasLogs] = useState(true); // Use default true to avoid frontend flickering with HasLogs component
+ const [isLoading, setIsLoading] = useState(false);
+ const { ECApiUrl } = useDappnodeUrls();
+ const { publicRuntimeConfig } = getConfig();
+
+ const contractTx = useMemo(
+ () => ({
+ [CHAINS.Mainnet]: `0xf5330dbcf09885ed145c4435e356b5d8a10054751bb8009d3a2605d476ac173f`,
+ [CHAINS.Holesky]: `0x1475719ecbb73b28bc531bb54b37695df1bf6b71c6d2bf1d28b4efa404867e26`,
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ const getSyncStatus = async () => {
+ try {
+ setIsLoading(true);
+ const syncResponse = await fetch(`${ECApiUrl}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'eth_syncing',
+ params: [],
+ id: 0,
+ }),
+ });
+ if (!syncResponse.ok) {
+ publicRuntimeConfig.defaultChain;
+ throw new Error(`HTTP error! Status: ${syncResponse.status}`);
+ }
+
+ const syncData = await syncResponse.json();
+
+ setIsInstalled(true);
+ setIsSynced(syncData.result ? false : true);
+ setIsLoading(false);
+ } catch (e) {
+ console.error(`Error getting EC data: ${e}`);
+ setIsInstalled(false);
+ setIsLoading(false);
+ }
+ };
+
+ void getSyncStatus();
+ }, [ECApiUrl, publicRuntimeConfig.defaultChain]);
+
+ useEffect(() => {
+ const getTxStatus = async () => {
+ try {
+ setIsLoading(true);
+
+ const txResponse = await fetch(`${ECApiUrl}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'eth_getTransactionReceipt',
+ params: [
+ contractTx[
+ publicRuntimeConfig.defaultChain as keyof typeof contractTx
+ ],
+ ],
+ id: 0,
+ }),
+ });
+
+ if (!txResponse.ok) {
+ throw new Error(`HTTP error! Status: ${txResponse.status}`);
+ }
+
+ const txData = await txResponse.json();
+
+ setHasLogs(txData.result ? true : false);
+ setIsLoading(false);
+ } catch (e) {
+ console.error(`Error getting EC data: ${e}`);
+ setIsLoading(false);
+ }
+ };
+
+ void getTxStatus();
+ }, [ECApiUrl, contractTx, isSynced, publicRuntimeConfig.defaultChain]);
+
+ return { isSynced, isInstalled, hasLogs, isLoading };
+};
diff --git a/dappnode/hooks/use-exit-requested-keys-from-events-api.ts b/dappnode/hooks/use-exit-requested-keys-from-events-api.ts
new file mode 100644
index 00000000..28b76e1f
--- /dev/null
+++ b/dappnode/hooks/use-exit-requested-keys-from-events-api.ts
@@ -0,0 +1,74 @@
+import { useLidoSWR } from '@lido-sdk/react';
+import { STRATEGY_LAZY } from 'consts/swr-strategies';
+import { useNodeOperatorId } from 'providers/node-operator-provider';
+import { useCallback } from 'react';
+import { useAccount } from 'shared/hooks';
+import useDappnodeUrls from './use-dappnode-urls';
+import { fetchWithRetry } from 'dappnode/utils/fetchWithRetry';
+
+interface ExitRequest {
+ event: {
+ [key: string]: any;
+ };
+ [key: string]: any;
+ validator_pubkey_hex: string;
+}
+type ExitRequests = Record;
+
+const parseEvents = (data: ExitRequests) => {
+ return Object.values(data).map((event: ExitRequest) => ({
+ validatorPubkey: event.validator_pubkey_hex.toLowerCase(),
+ blockNumber: parseInt(event.event.Raw.blockNumber, 16),
+ }));
+};
+
+const restoreEvents = (
+ events: { validatorPubkey: string; blockNumber: number }[],
+) =>
+ events
+ .sort((a, b) => a.blockNumber - b.blockNumber)
+ .map((e) => e.validatorPubkey);
+
+export const useExitRequestedKeysFromEvents = () => {
+ const { backendUrl } = useDappnodeUrls();
+ const { chainId } = useAccount();
+ const nodeOperatorId = useNodeOperatorId();
+
+ const fetcher = useCallback(async () => {
+ if (!nodeOperatorId) {
+ console.error('Node Operator ID is required to fetch exit requests');
+ return [];
+ }
+
+ try {
+ console.debug(
+ `Fetching exit requests for Node Operator ID: ${nodeOperatorId}`,
+ );
+ const url = `${backendUrl}/api/v0/events_indexer/exit_requests?operatorId=${nodeOperatorId}`;
+ const options = {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ };
+
+ const response = await fetchWithRetry(url, options, 5000);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data: ExitRequests = await response.json();
+ const events = parseEvents(data);
+
+ return restoreEvents(events);
+ } catch (e) {
+ console.error(`Error fetching exit request events: ${e}`);
+ return [];
+ }
+ }, [backendUrl, nodeOperatorId]);
+
+ return useLidoSWR(
+ ['exit-requested-keys', nodeOperatorId, chainId],
+ nodeOperatorId ? fetcher : null,
+ STRATEGY_LAZY,
+ );
+};
diff --git a/dappnode/hooks/use-get-exit-requests.ts b/dappnode/hooks/use-get-exit-requests.ts
new file mode 100644
index 00000000..2a96701f
--- /dev/null
+++ b/dappnode/hooks/use-get-exit-requests.ts
@@ -0,0 +1,61 @@
+import { useCallback, useState } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+import { useActiveNodeOperator } from 'providers/node-operator-provider';
+import { fetchWithRetry } from 'dappnode/utils/fetchWithRetry';
+
+const useGetExitRequests = () => {
+ const { backendUrl } = useDappnodeUrls();
+ const [exitRequests, setExitRequests] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const nodeOperator = useActiveNodeOperator();
+
+ interface ExitRequest {
+ event: {
+ [key: string]: any;
+ };
+ [key: string]: any;
+ validator_pubkey_hex: string;
+ }
+ type ExitRequests = Record;
+
+ const getExitRequests = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ console.debug(`GETting validators exit requests from indexer API`);
+ const url = `${backendUrl}/api/v0/events_indexer/exit_requests?operatorId=${nodeOperator?.id}`;
+ const options = {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ };
+ const response = await fetchWithRetry(url, options, 5000);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data: ExitRequests = await response.json();
+
+ // Statuses to include if have not started/ended the exit process
+ const includedStatuses = ['active_ongoing', 'active_slashed'];
+
+ const filteredData = Object.fromEntries(
+ Object.entries(data).filter(([, exitRequest]) =>
+ includedStatuses.includes(exitRequest.status),
+ ),
+ );
+
+ setExitRequests(filteredData);
+ setIsLoading(false);
+ } catch (e) {
+ console.error(
+ `Error GETting validators exit requests from indexer API: ${e}`,
+ );
+ setIsLoading(false);
+ }
+ }, [backendUrl, nodeOperator]);
+
+ return { exitRequests, getExitRequests, isLoading };
+};
+
+export default useGetExitRequests;
diff --git a/dappnode/hooks/use-get-infra-status.ts b/dappnode/hooks/use-get-infra-status.ts
new file mode 100644
index 00000000..caf5ce40
--- /dev/null
+++ b/dappnode/hooks/use-get-infra-status.ts
@@ -0,0 +1,114 @@
+import { useEffect, useState } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+import { INFRA_STATUS } from 'dappnode/status/types';
+
+export const useGetInfraStatus = () => {
+ const { ECApiUrl, CCStatusApiUrl, CCVersionApiUrl } = useDappnodeUrls();
+ const [ECStatus, setECStatus] = useState();
+ const [CCStatus, setCCStatus] = useState();
+
+ const [ECName, setECName] = useState();
+ const [CCName, setCCName] = useState();
+
+ const [isECLoading, setIsECLoading] = useState(true);
+ const [isCCLoading, setIsCCLoading] = useState(true);
+
+ useEffect(() => {
+ const getCCData = async () => {
+ setIsCCLoading(true);
+ try {
+ const versionResponse = await fetch(CCVersionApiUrl, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (!versionResponse.ok) {
+ throw new Error(`HTTP error! Status: ${versionResponse.status}`);
+ }
+ const versionData = await versionResponse.json();
+ setCCName(versionData.data.version.split('/')[0]);
+
+ const syncResponse = await fetch(CCStatusApiUrl, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!syncResponse.ok) {
+ throw new Error(`HTTP error! Status: ${syncResponse.status}`);
+ }
+
+ const data = await syncResponse.json();
+ setCCStatus(data.data.is_syncing ? 'SYNCING' : 'SYNCED');
+ setIsCCLoading(false);
+ } catch (e) {
+ console.error(`Error getting CC data: ${e}`);
+ setCCStatus('NOT_INSTALLED');
+ setIsCCLoading(false);
+ }
+ };
+
+ const getECData = async () => {
+ setIsECLoading(true);
+ try {
+ const versionResponse = await fetch(`${ECApiUrl}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'web3_clientVersion',
+ params: [],
+ id: 1,
+ }),
+ });
+
+ if (!versionResponse.ok) {
+ throw new Error(`HTTP error! Status: ${versionResponse.status}`);
+ }
+ const versionData = await versionResponse.json();
+ setECName(versionData.result.split('/')[0]);
+
+ const syncResponse = await fetch(`${ECApiUrl}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'eth_syncing',
+ params: [],
+ id: 0,
+ }),
+ });
+
+ if (!syncResponse.ok) {
+ throw new Error(`HTTP error! Status: ${syncResponse.status}`);
+ }
+
+ const syncData = await syncResponse.json();
+ setECStatus(syncData.result ? 'SYNCING' : 'SYNCED');
+ setIsECLoading(false);
+ } catch (e) {
+ console.error(`Error getting EC data: ${e}`);
+ setECStatus('NOT_INSTALLED');
+ setIsECLoading(false);
+ }
+ };
+
+ void getECData();
+ void getCCData();
+ }, [CCStatusApiUrl, CCVersionApiUrl, ECApiUrl]);
+
+ return {
+ CCName,
+ CCStatus,
+ ECName,
+ ECStatus,
+ isECLoading,
+ isCCLoading,
+ };
+};
diff --git a/dappnode/hooks/use-get-next-report.ts b/dappnode/hooks/use-get-next-report.ts
new file mode 100644
index 00000000..3b6b2e0a
--- /dev/null
+++ b/dappnode/hooks/use-get-next-report.ts
@@ -0,0 +1,28 @@
+import { useChainId } from 'wagmi';
+import { CONSTANTS_BY_NETWORK, getCsmConstants } from 'consts/csm-constants';
+
+export const useGetNextReport = () => {
+ const chainId = useChainId() as keyof typeof CONSTANTS_BY_NETWORK;
+ const currentTimestamp = Math.floor(Date.now() / 1000);
+
+ const deploymentTimestamp = getCsmConstants(chainId).reportTimestamp;
+
+ const reportsIntervalDays = chainId === 1 ? 28 : 7;
+ const reportsIntervalSeconds = reportsIntervalDays * 24 * 60 * 60;
+
+ const secondsSinceDeployment = currentTimestamp - deploymentTimestamp;
+
+ const reportsCompleted = Math.floor(
+ secondsSinceDeployment / reportsIntervalSeconds,
+ );
+
+ const nextReportTimestamp =
+ deploymentTimestamp + (reportsCompleted + 1) * reportsIntervalSeconds; // +1 because we want the next report
+
+ const secondsUntilNextReport = nextReportTimestamp - currentTimestamp;
+ const daysUntilNextReport = Math.ceil(
+ secondsUntilNextReport / (24 * 60 * 60),
+ );
+
+ return daysUntilNextReport;
+};
diff --git a/dappnode/hooks/use-get-operator-performance.ts b/dappnode/hooks/use-get-operator-performance.ts
new file mode 100644
index 00000000..b279b61a
--- /dev/null
+++ b/dappnode/hooks/use-get-operator-performance.ts
@@ -0,0 +1,62 @@
+import { useNodeOperatorsList } from 'providers/node-operator-provider/use-node-operators-list';
+import useDappnodeUrls from './use-dappnode-urls';
+import { useGetActiveNodeOperator } from 'providers/node-operator-provider/use-get-active-node-operator';
+import { useEffect, useState } from 'react';
+
+export const useGetOperatorPerformance = () => {
+ const { backendUrl } = useDappnodeUrls();
+ const { list } = useNodeOperatorsList();
+ const { active: activeNO } = useGetActiveNodeOperator(list);
+ const [operatorData, setOperatorData] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ const getDataWithRetry = async () => {
+ const url = `${backendUrl}/api/v0/events_indexer/operator_performance?operatorId=${activeNO?.id}`;
+ const options = {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ };
+ const retryInterval = 5000; // 5 seconds
+
+ setIsLoading(true);
+
+ const shouldRetry = true;
+ while (shouldRetry) {
+ try {
+ console.debug(
+ `Fetching operator performance data from events indexer API...`,
+ );
+ const response = await fetch(url, options);
+
+ if (response.status === 202) {
+ console.debug(
+ `Received status 202. Retrying in ${retryInterval / 1000} seconds...`,
+ );
+ await new Promise((resolve) => setTimeout(resolve, retryInterval));
+ continue;
+ }
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setOperatorData(data);
+ break; // Exit loop when successful
+ } catch (e) {
+ console.error(`Error fetching operator performance data: ${e}`);
+ break; // Stop retrying on other errors
+ }
+ }
+
+ setIsLoading(false);
+ };
+
+ if (activeNO) {
+ void getDataWithRetry();
+ }
+ }, [activeNO, backendUrl]);
+
+ return { operatorData, isLoading };
+};
diff --git a/dappnode/hooks/use-get-pending-reports.ts b/dappnode/hooks/use-get-pending-reports.ts
new file mode 100644
index 00000000..bf944a56
--- /dev/null
+++ b/dappnode/hooks/use-get-pending-reports.ts
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+import { useNodeOperatorsList } from 'providers/node-operator-provider/use-node-operators-list';
+import { useGetActiveNodeOperator } from 'providers/node-operator-provider/use-get-active-node-operator';
+
+export const useGetPendingReports = () => {
+ const { backendUrl } = useDappnodeUrls();
+ const { list } = useNodeOperatorsList();
+ const { active: activeNO } = useGetActiveNodeOperator(list);
+
+ const [pendingReports, setPendingReports] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ const getPendingReports = async () => {
+ setIsLoading(true);
+ try {
+ console.debug(`GETting pending reports from events indexer API`);
+ const response = await fetch(
+ `${backendUrl}/api/v0/events_indexer/pending_hashes?operatorId=${activeNO?.id}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setPendingReports(data.length);
+ setIsLoading(false);
+ } catch (e) {
+ console.error(
+ `Error GETting pending reports from events indexer API: ${e}`,
+ );
+ setIsLoading(false);
+ }
+ };
+
+ if (activeNO) {
+ void getPendingReports();
+ }
+ }, [activeNO, backendUrl]);
+
+ return { isLoading, pendingReports };
+};
diff --git a/dappnode/hooks/use-get-performance-by-range.ts b/dappnode/hooks/use-get-performance-by-range.ts
new file mode 100644
index 00000000..cc875e7b
--- /dev/null
+++ b/dappnode/hooks/use-get-performance-by-range.ts
@@ -0,0 +1,175 @@
+import { useEffect, useState } from 'react';
+import { useGetOperatorPerformance } from './use-get-operator-performance';
+import { Range, ValidatorStats } from '../performance/types';
+import { useAccount } from 'shared/hooks';
+
+export const useGetPerformanceByRange = (range: Range) => {
+ const { operatorData, isLoading } = useGetOperatorPerformance();
+ const [operatorDataByRange, setOperatorDataByRange] = useState<
+ Record
+ >({});
+ const [validatorsStats, setValidatorsStats] = useState([]);
+ const [threshold, setThreshold] = useState(0);
+ const [thresholdsByEpoch, setThresholdsByEpoch] = useState([]);
+
+ const { chainId } = useAccount();
+
+ const epochRanges: Record = {
+ week: 1575, // 7 days * 225 epochs / day
+ month: 7650, // 30 days * 225 epochs / day
+ year: 82125, // 365 days * 225 epochs / day
+ ever: Infinity,
+ };
+
+ const epochsInRange = epochRanges[range];
+
+ useEffect(() => {
+ if (!operatorData) return;
+
+ const sortedKeys = Object.keys(operatorData).sort((a, b) => {
+ const [startA] = a.split('-').map(Number);
+ const [startB] = b.split('-').map(Number);
+ return startB - startA; // Sort descending by epoch start
+ });
+
+ let totalEpochs = 0;
+ const filteredData: Record = {};
+ let previousEntry: string | null = null;
+
+ // Filter data based on range for operatorDataByRange
+ for (const key of sortedKeys) {
+ const [startEpoch, endEpoch] = key.split('-').map(Number);
+ const epochDiff = endEpoch - startEpoch;
+
+ if (totalEpochs + epochDiff > epochsInRange) {
+ if (chainId === 1) {
+ if (range === 'month' && !previousEntry) {
+ previousEntry = key;
+ }
+ } else {
+ if (range === 'week' && !previousEntry) {
+ previousEntry = key;
+ }
+ }
+ break;
+ }
+
+ filteredData[key] = operatorData[key];
+ totalEpochs += epochDiff;
+ }
+
+ setOperatorDataByRange(filteredData);
+
+ // Filter data for thresholdsByEpoch
+ const thresholdsData = { ...filteredData };
+ if (chainId === 1) {
+ if (range === 'month' && previousEntry) {
+ thresholdsData[previousEntry] = operatorData[previousEntry];
+ }
+ } else {
+ if (range === 'week' && previousEntry && !thresholdsData[previousEntry]) {
+ thresholdsData[previousEntry] = operatorData[previousEntry];
+ }
+ }
+
+ setThresholdsByEpoch(
+ Object.entries(thresholdsData)
+ .map(([_, value]) => {
+ const endFrame = value.frame[1].toString();
+ const lidoThreshold = value.threshold * 100; // Convert to percentage
+
+ const validatorRatios = Object.entries(value.data.validators).reduce(
+ (acc, [validatorId, validatorData]) => {
+ const validatorPerf = (validatorData as any).perf;
+ acc[validatorId] =
+ (validatorPerf.included / validatorPerf.assigned) * 100; // Convert to percentage
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return {
+ name: endFrame,
+ lidoThreshold,
+ ...validatorRatios,
+ };
+ })
+ .reverse(), // Reverse for oldest first
+ );
+ }, [chainId, epochsInRange, operatorData, range]);
+
+ useEffect(() => {
+ if (!operatorDataByRange) return;
+
+ const statsPerValidator: { [key: string]: ValidatorStats[] } = {};
+ const thresholds: number[] = [];
+
+ // Process data for validatorsStats
+ for (const key of Object.keys(operatorDataByRange)) {
+ const validatorsData = operatorDataByRange[key]?.data?.validators || {};
+ thresholds.push(operatorDataByRange[key]?.threshold);
+
+ for (const validator of Object.keys(validatorsData)) {
+ if (!statsPerValidator[validator]) {
+ statsPerValidator[validator] = [];
+ }
+
+ const validatorPerf = validatorsData[validator].perf;
+ const attestations = {
+ included: validatorPerf.included,
+ assigned: validatorPerf.assigned,
+ };
+
+ statsPerValidator[validator].push({
+ index: parseInt(validator),
+ attestations,
+ efficiency: validatorPerf.included / validatorPerf.assigned,
+ });
+ }
+ }
+
+ // Calculate average threshold
+ setThreshold(
+ thresholds.reduce((sum, value) => sum + value, 0) / thresholds.length,
+ );
+
+ const getValidatorStats = (
+ data: Record,
+ ): ValidatorStats[] => {
+ return Object.entries(data).map(([key, entries]) => {
+ const totalAssigned = entries.reduce(
+ (sum, entry) => sum + entry.attestations.assigned,
+ 0,
+ );
+ const totalIncluded = entries.reduce(
+ (sum, entry) => sum + entry.attestations.included,
+ 0,
+ );
+ const totalEfficiency = entries.reduce(
+ (sum, entry) => sum + (entry.efficiency || 0),
+ 0,
+ );
+
+ return {
+ index: parseInt(key, 10),
+ attestations: {
+ assigned: totalAssigned,
+ included: totalIncluded,
+ },
+ efficiency: totalEfficiency / entries.length,
+ };
+ });
+ };
+
+ // Calculate stats for validators
+ const result = getValidatorStats(statsPerValidator);
+ setValidatorsStats(result);
+ }, [operatorDataByRange]);
+
+ return {
+ isLoading,
+ validatorsStats,
+ threshold,
+ thresholdsByEpoch,
+ };
+};
diff --git a/dappnode/hooks/use-get-relays-data.ts b/dappnode/hooks/use-get-relays-data.ts
new file mode 100644
index 00000000..d3135efd
--- /dev/null
+++ b/dappnode/hooks/use-get-relays-data.ts
@@ -0,0 +1,161 @@
+import { useEffect, useState } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+import { AllowedRelay } from 'dappnode/status/types';
+import { sanitizeUrl } from 'dappnode/utils/sanitize-urls';
+
+const useGetRelaysData = () => {
+ const { backendUrl, MEVApiUrl } = useDappnodeUrls();
+
+ const [isLoading, setIsLoading] = useState(true);
+
+ const [allowedRelays, setAllowedRelays] = useState();
+ const [usedRelays, setUsedRelays] = useState<[]>();
+ const [relaysError, setRelaysError] = useState();
+ const [isMEVRunning, setIsMEVRunning] = useState();
+ const [mandatoryRelays, setMandatoryRelays] = useState();
+ const [hasMandatoryRelay, setHasMandatoryRelay] = useState();
+ const [usedBlacklistedRelays, setUsedBlacklistedRelays] = useState(
+ [],
+ );
+
+ useEffect(() => {
+ const getMEVStatus = async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(MEVApiUrl, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ await response.json();
+ setIsMEVRunning(true);
+ setIsLoading(false);
+ } catch (e) {
+ setIsMEVRunning(false);
+ console.error('Error GETting MEV PKG Status:', e);
+ setIsLoading(false);
+ }
+ };
+
+ void getMEVStatus();
+ }, [MEVApiUrl]);
+
+ useEffect(() => {
+ const getAllowedRelays = async () => {
+ try {
+ console.debug(`GETting allowed relays from events indexer API`);
+ const response = await fetch(
+ `${backendUrl}/api/v0/events_indexer/relays_allowed`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const cleanedData = data.map((relay: { Uri: string }) => ({
+ ...relay,
+ Uri: sanitizeUrl(relay.Uri),
+ }));
+
+ setAllowedRelays(cleanedData);
+ } catch (e) {
+ console.error(`Error GETting allowed relays from indexer API: ${e}`);
+ }
+ };
+
+ const getUsedRelays = async () => {
+ try {
+ console.debug(`GETting used relays from events indexer API`);
+ const response = await fetch(
+ `${backendUrl}/api/v0/events_indexer/relays_used`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const cleanedData = data.map((url: string) => sanitizeUrl(url));
+ setUsedRelays(cleanedData);
+ } catch (e) {
+ setRelaysError(e);
+ console.error(`Error GETting used relays from indexer API: ${e}`);
+ }
+ };
+
+ void getAllowedRelays();
+ void getUsedRelays();
+ }, [isMEVRunning, backendUrl]);
+
+ useEffect(() => {
+ if (allowedRelays) {
+ setMandatoryRelays(allowedRelays.filter((relay) => relay.IsMandatory));
+ }
+ }, [allowedRelays]);
+
+ useEffect(() => {
+ const filterMandatoryRelays = (
+ mandatoryRelays: AllowedRelay[],
+ usedRelays: string[],
+ ) => {
+ return usedRelays.some((usedRelay) =>
+ mandatoryRelays.some((relay) => relay.Uri === usedRelay),
+ );
+ };
+
+ if (mandatoryRelays && usedRelays) {
+ const hasMandatoryRelay = filterMandatoryRelays(
+ mandatoryRelays,
+ usedRelays,
+ );
+ setHasMandatoryRelay(hasMandatoryRelay);
+ }
+ }, [mandatoryRelays, usedRelays]);
+
+ useEffect(() => {
+ if (allowedRelays && usedRelays) {
+ const allowedUris = allowedRelays.map((relay) => relay.Uri);
+
+ // Filter out uris from usedRelays that are not in allowedRelays
+ const blacklisted = usedRelays.filter(
+ (uri) => !allowedUris.includes(uri),
+ );
+
+ setUsedBlacklistedRelays(blacklisted);
+ }
+ }, [usedRelays, allowedRelays]);
+
+ return {
+ allowedRelays,
+ usedRelays,
+ relaysError,
+ isMEVRunning,
+ hasMandatoryRelay,
+ mandatoryRelays,
+ isLoading,
+ usedBlacklistedRelays,
+ };
+};
+
+export default useGetRelaysData;
diff --git a/dappnode/hooks/use-get-telegram-data.ts b/dappnode/hooks/use-get-telegram-data.ts
new file mode 100644
index 00000000..f35c7db2
--- /dev/null
+++ b/dappnode/hooks/use-get-telegram-data.ts
@@ -0,0 +1,43 @@
+import { useCallback, useState } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+
+const useGetTelegramData = () => {
+ const { backendUrl } = useDappnodeUrls();
+ const [telegramId, setTelegramData] = useState();
+ const [botToken, setBotToken] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const getTelegramData = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ console.debug(`GETting telegram data from events indexer API`);
+ const response = await fetch(
+ `${backendUrl}/api/v0/events_indexer/telegramConfig`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTelegramData(data.userId);
+ setBotToken(data.token);
+ setIsLoading(false);
+ } catch (e) {
+ console.error(
+ `Error GETting telegram data from events indexer API: ${e}`,
+ );
+ setIsLoading(false);
+ }
+ }, [backendUrl]);
+
+ return { telegramId, botToken, getTelegramData, isLoading };
+};
+
+export default useGetTelegramData;
diff --git a/dappnode/hooks/use-invites-events-fetcher-api.ts b/dappnode/hooks/use-invites-events-fetcher-api.ts
new file mode 100644
index 00000000..87565024
--- /dev/null
+++ b/dappnode/hooks/use-invites-events-fetcher-api.ts
@@ -0,0 +1,135 @@
+import { ROLES } from 'consts/roles';
+import { useCallback } from 'react';
+import { useAccount, useAddressCompare } from 'shared/hooks';
+import { getInviteId } from 'shared/node-operator';
+import { NodeOperatorInvite } from 'types';
+import { getNodeOperatorIdFromEvent } from 'utils';
+import useDappnodeUrls from './use-dappnode-urls';
+import {
+ NodeOperatorManagerAddressChangeProposedEvent,
+ NodeOperatorRewardAddressChangeProposedEvent,
+ NodeOperatorManagerAddressChangedEvent,
+ NodeOperatorRewardAddressChangedEvent,
+} from 'generated/CSModule';
+import { fetchWithRetry } from 'dappnode/utils/fetchWithRetry';
+
+type AddressChangeProposedEvents =
+ | NodeOperatorManagerAddressChangeProposedEvent
+ | NodeOperatorRewardAddressChangeProposedEvent
+ | NodeOperatorManagerAddressChangedEvent
+ | NodeOperatorRewardAddressChangedEvent;
+
+const parseEvents = (data: any) => {
+ return [
+ ...(data.nodeOperatorManagerAddressChangeProposed || []).map(
+ (event: any) => ({
+ event: 'NodeOperatorManagerAddressChangeProposed',
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ newAddress: event.NewAddress,
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ }),
+ ),
+ ...(data.nodeOperatorRewardAddressChangeProposed || []).map(
+ (event: any) => ({
+ event: 'NodeOperatorRewardAddressChangeProposed',
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ newAddress: event.NewAddress,
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ }),
+ ),
+ ...(data.nodeOperatorManagerAddressChanged || []).map((event: any) => ({
+ event: 'NodeOperatorManagerAddressChanged',
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ oldAddress: event.OldAddress,
+ newAddress: event.NewAddress,
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ })),
+ ...(data.nodeOperatorRewardAddressChanged || []).map((event: any) => ({
+ event: 'NodeOperatorRewardAddressChanged',
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ oldAddress: event.OldAddress,
+ newAddress: event.NewAddress,
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ })),
+ ];
+};
+
+export const useInvitesEventsFetcher = () => {
+ const { address } = useAccount();
+ const { backendUrl } = useDappnodeUrls();
+ const isUserAddress = useAddressCompare();
+
+ const restoreEvents = useCallback(
+ (events: AddressChangeProposedEvents[]) => {
+ const invitesMap: Map = new Map();
+
+ const updateRoles = (invite: NodeOperatorInvite, add = true) => {
+ const id = getInviteId(invite);
+ if (add) {
+ invitesMap.set(id, invite);
+ } else {
+ invitesMap.delete(id);
+ }
+ };
+
+ events
+ .sort((a, b) => a.blockNumber - b.blockNumber)
+ .forEach((e) => {
+ const id = getNodeOperatorIdFromEvent(e);
+ switch (e.event) {
+ case 'NodeOperatorManagerAddressChangeProposed':
+ return isUserAddress(e.args[2])
+ ? updateRoles({ id, role: ROLES.MANAGER })
+ : updateRoles({ id, role: ROLES.MANAGER }, false);
+ case 'NodeOperatorRewardAddressChangeProposed':
+ return isUserAddress(e.args[2])
+ ? updateRoles({ id, role: ROLES.REWARDS })
+ : updateRoles({ id, role: ROLES.REWARDS }, false);
+ case 'NodeOperatorManagerAddressChanged':
+ return updateRoles({ id, role: ROLES.MANAGER }, false);
+ case 'NodeOperatorRewardAddressChanged':
+ return updateRoles({ id, role: ROLES.REWARDS }, false);
+ default:
+ return;
+ }
+ });
+
+ return Array.from(invitesMap.values()).sort(
+ (a, b) =>
+ parseInt(a.id, 10) - parseInt(b.id, 10) ||
+ -Number(b.role === ROLES.REWARDS) - Number(a.role === ROLES.REWARDS),
+ );
+ },
+ [isUserAddress],
+ );
+
+ const fetcher = useCallback(async () => {
+ try {
+ console.debug(`Fetching invite events for address: ${address}`);
+ const url = `${backendUrl}/api/v0/events_indexer/address_events?address=${address}`;
+ const options = {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ };
+
+ // Retry logic for 202 status
+ const response = await fetchWithRetry(url, options, 5000);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ const events = parseEvents(data);
+
+ console.debug('Parsed invite events:', events);
+
+ return restoreEvents(events);
+ } catch (e) {
+ console.error(`Error fetching invite events: ${e}`);
+ return [];
+ }
+ }, [backendUrl, address, restoreEvents]);
+
+ return fetcher;
+};
diff --git a/dappnode/hooks/use-missing-keys.ts b/dappnode/hooks/use-missing-keys.ts
new file mode 100644
index 00000000..4883a02d
--- /dev/null
+++ b/dappnode/hooks/use-missing-keys.ts
@@ -0,0 +1,96 @@
+import { useEffect, useState } from 'react';
+import { useKeysWithStatus } from 'shared/hooks';
+import useApiBrain from './use-brain-keystore-api';
+import useDappnodeUrls from './use-dappnode-urls';
+
+const useMissingKeys = () => {
+ const [missingKeys, setMissingKeys] = useState([]);
+ const [keysLoading, setKeysLoading] = useState(false);
+
+ const {
+ pubkeys: brainKeys,
+ isLoading: brainKeysLoading,
+ error,
+ } = useApiBrain();
+ const { data: lidoKeys, initialLoading: lidoKeysLoading } =
+ useKeysWithStatus();
+
+ const { keysStatusUrl } = useDappnodeUrls();
+
+ useEffect(() => {
+ if (lidoKeysLoading || brainKeysLoading) {
+ setKeysLoading(true);
+ } else {
+ setKeysLoading(false);
+ }
+ }, [lidoKeysLoading, brainKeysLoading]);
+
+ useEffect(() => {
+ const setActiveKeys = async (missingKeys_: string[]): Promise => {
+ if (missingKeys_.length > 0) {
+ const params = new URLSearchParams({
+ id: missingKeys_.join(','),
+ status:
+ 'withdrawal_done,withdrawal_possible,exited_unslashed,exited_slashed',
+ });
+
+ const inactiveKeys: string[] = [];
+ try {
+ const response = await fetch(
+ `${keysStatusUrl}?${params.toString()}`,
+ {
+ method: 'GET',
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ for (const key of data.data) {
+ inactiveKeys.push(key.validator.pubkey);
+ }
+
+ const lidoActiveKeys = missingKeys_.filter(
+ (key) => !inactiveKeys.includes(key),
+ );
+
+ setMissingKeys(lidoActiveKeys);
+ } catch (error) {
+ console.error('Error fetching validators:', error);
+ }
+ } else {
+ setMissingKeys([]);
+ }
+ };
+
+ const filterLidoKeys = async () => {
+ if (lidoKeys) {
+ const formattedLidoKeys = [];
+ for (const key of lidoKeys) {
+ formattedLidoKeys.push(key.key.toLowerCase());
+ }
+
+ const formattedBrainKeys = brainKeys
+ ? brainKeys.map((key) => key.toLowerCase())
+ : [];
+
+ const missingLidoKeys = formattedLidoKeys.filter(
+ (lidoKey) => !formattedBrainKeys.includes(lidoKey),
+ );
+
+ await setActiveKeys(missingLidoKeys);
+ }
+ };
+
+ if (brainKeys && lidoKeys) {
+ void filterLidoKeys();
+ }
+ }, [brainKeys, keysStatusUrl, lidoKeys]);
+
+ return { missingKeys, keysLoading, error };
+};
+
+export default useMissingKeys;
diff --git a/dappnode/hooks/use-node-operators-fetcher-from-events-api.ts b/dappnode/hooks/use-node-operators-fetcher-from-events-api.ts
new file mode 100644
index 00000000..1703d7b7
--- /dev/null
+++ b/dappnode/hooks/use-node-operators-fetcher-from-events-api.ts
@@ -0,0 +1,163 @@
+import { useCallback } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+import { NodeOperator } from 'types';
+import { compareLowercase, mergeRoles } from 'utils';
+import { fetchWithRetry } from 'dappnode/utils/fetchWithRetry';
+
+/**
+ * This hook acts as an alternative to the hook `useNodeOperatorsFetcherFromEvents`.
+ * Fetching the events "NodeOperatorAdded", "NodeOperatorManagerAddressChanged" and "NodeOperatorRewardAddressChanged"
+ * directly to an RPC endpoint can take several minutes specially in ethereum mainnet.
+ *
+ * This hook fetches the events from an API endpoint of the lido-events backend that fetches the events and indexes them for
+ * faster retrieval.
+ *
+ * In the first login of the user this fetch can take several minutes, but in the next logins the events are fetched from the cache til the latest block.
+ *
+ * The lido-events backend will return http code 202 if the address is still being processed, in that case the hook will retry the fetch every 5 seconds and must never
+ * resolve the promise to keep the isListLoading being true.
+ *
+ * stats:
+ * - Geth: around 1.5 minutes
+ * - Besu: around 7 minutes
+ */
+
+type NodeOperatorRoleEvent =
+ | {
+ event: 'NodeOperatorAdded';
+ nodeOperatorId: string;
+ managerAddress: string;
+ rewardAddress: string;
+ blockNumber: number;
+ }
+ | {
+ event: 'NodeOperatorManagerAddressChanged';
+ nodeOperatorId: string;
+ oldAddress: string;
+ newAddress: string;
+ blockNumber: number;
+ }
+ | {
+ event: 'NodeOperatorRewardAddressChanged';
+ nodeOperatorId: string;
+ oldAddress: string;
+ newAddress: string;
+ blockNumber: number;
+ };
+
+const restoreEvents = (
+ events: NodeOperatorRoleEvent[],
+ address?: string,
+): NodeOperator[] => {
+ const isUserAddress = (value: string) => compareLowercase(address, value);
+
+ return events
+ .sort((a, b) => a.blockNumber - b.blockNumber)
+ .reduce((prev, e) => {
+ const id: `${number}` = `${parseInt(e.nodeOperatorId)}`;
+ switch (e.event) {
+ case 'NodeOperatorAdded':
+ return mergeRoles(prev, {
+ id,
+ manager: isUserAddress(e.managerAddress),
+ rewards: isUserAddress(e.rewardAddress),
+ });
+ case 'NodeOperatorManagerAddressChanged':
+ return mergeRoles(prev, {
+ id,
+ manager: isUserAddress(e.newAddress),
+ });
+ case 'NodeOperatorRewardAddressChanged':
+ return mergeRoles(prev, {
+ id,
+ rewards: isUserAddress(e.newAddress),
+ });
+ default:
+ return prev;
+ }
+ }, [] as NodeOperator[]);
+};
+
+const parseEvents = (data: any): NodeOperatorRoleEvent[] => {
+ const {
+ nodeOperatorAdded = [],
+ nodeOperatorManagerAddressChanged = [],
+ nodeOperatorRewardAddressChanged = [],
+ } = data;
+
+ const parsedAddedEvents = nodeOperatorAdded.map((event: any) => ({
+ event: 'NodeOperatorAdded',
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ managerAddress: event.ManagerAddress,
+ rewardAddress: event.RewardAddress,
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ }));
+
+ const parsedManagerChangedEvents = nodeOperatorManagerAddressChanged.map(
+ (event: any) => ({
+ event: 'NodeOperatorManagerAddressChanged',
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ oldAddress: event.OldAddress,
+ newAddress: event.NewAddress,
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ }),
+ );
+
+ const parsedRewardChangedEvents = nodeOperatorRewardAddressChanged.map(
+ (event: any) => ({
+ event: 'NodeOperatorRewardAddressChanged',
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ oldAddress: event.OldAddress,
+ newAddress: event.NewAddress,
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ }),
+ );
+
+ return [
+ ...parsedAddedEvents,
+ ...parsedManagerChangedEvents,
+ ...parsedRewardChangedEvents,
+ ];
+};
+
+export const useNodeOperatorsFetcherFromAPI = (address?: string) => {
+ const { backendUrl } = useDappnodeUrls();
+
+ return useCallback(async () => {
+ if (!address) {
+ console.error('Address is required to fetch Node Operator events');
+ return [];
+ }
+
+ try {
+ console.debug(
+ `Fetching events associated with Node Operator address: ${address}`,
+ );
+
+ const url = `${backendUrl}/api/v0/events_indexer/address_events?address=${address}`;
+ const options = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ // Retry logic for 202 status
+ const response = await fetchWithRetry(url, options, 5000);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ const events = parseEvents(data);
+
+ console.debug('Parsed events:', events);
+
+ return restoreEvents(events, address);
+ } catch (e) {
+ console.error(`Error fetching Node Operator events from API: ${e}`);
+ return [];
+ }
+ }, [backendUrl, address]);
+};
diff --git a/dappnode/hooks/use-node-operators-with-locked-bond-api.ts b/dappnode/hooks/use-node-operators-with-locked-bond-api.ts
new file mode 100644
index 00000000..51cd5610
--- /dev/null
+++ b/dappnode/hooks/use-node-operators-with-locked-bond-api.ts
@@ -0,0 +1,94 @@
+import { useLidoSWR } from '@lido-sdk/react';
+import { STRATEGY_IMMUTABLE } from 'consts/swr-strategies';
+import { BigNumber } from 'ethers';
+import { ELRewardsStealingPenaltyReportedEvent } from 'generated/CSModule';
+import { useCallback } from 'react';
+import { useAccount, useCSAccountingRPC, useMergeSwr } from 'shared/hooks';
+import { NodeOperatorId } from 'types';
+import useDappnodeUrls from './use-dappnode-urls';
+import { fetchWithRetry } from 'dappnode/utils/fetchWithRetry';
+
+const parseEvents = (data: any): ELRewardsStealingPenaltyReportedEvent[] => {
+ return data.map((event: any) => ({
+ nodeOperatorId: `${parseInt(event.NodeOperatorId)}`,
+ proposedBlockHash: event.ProposedBlockHash as string,
+ stolenAmount: BigNumber.from(event.StolenAmount),
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ }));
+};
+
+const restoreEvents = (
+ events: ELRewardsStealingPenaltyReportedEvent[],
+): string[] =>
+ events
+ .map((e) => e.args.nodeOperatorId.toString())
+ .filter((value, index, array) => array.indexOf(value) === index)
+ .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
+
+const useLockedNodeOperatorsFromAPI = () => {
+ const { backendUrl } = useDappnodeUrls();
+ const { chainId } = useAccount();
+
+ const fetcher = useCallback(async (): Promise => {
+ try {
+ console.debug('Fetching EL rewards stealing penalties...');
+ const url = `${backendUrl}/api/v0/events_indexer/el_rewards_stealing_penalties_reported`;
+ const options = {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ };
+
+ // Retry logic for 202 status
+ const response = await fetchWithRetry(url, options, 5000);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ const events = parseEvents(data);
+
+ console.debug('Parsed EL rewards stealing penalties:', events);
+
+ return restoreEvents(events);
+ } catch (e) {
+ console.error(`Error fetching EL rewards stealing penalties: ${e}`);
+ return [];
+ }
+ }, [backendUrl]);
+
+ return useLidoSWR(
+ ['locked-node-operators-ids', chainId],
+ fetcher,
+ STRATEGY_IMMUTABLE,
+ );
+};
+
+export type LockedOperator = [NodeOperatorId, BigNumber];
+
+export const useNodeOperatorsWithLockedBond = () => {
+ const swrNodeOperators = useLockedNodeOperatorsFromAPI();
+ const contract = useCSAccountingRPC();
+ const { chainId } = useAccount();
+
+ const nodeOperatorIds = swrNodeOperators.data;
+
+ const fetcher = useCallback(async (): Promise => {
+ if (!nodeOperatorIds) return [];
+
+ const promises = nodeOperatorIds.map(
+ async (id) =>
+ [id, await contract.getActualLockedBond(id)] as LockedOperator,
+ );
+ const result = await Promise.all(promises);
+ return result.filter((r) => r[1].gt(0));
+ }, [contract, nodeOperatorIds]);
+
+ const swrList = useLidoSWR(
+ ['locked-node-operators', chainId, nodeOperatorIds],
+ nodeOperatorIds && chainId ? fetcher : null,
+ STRATEGY_IMMUTABLE,
+ );
+
+ return useMergeSwr([swrNodeOperators, swrList], swrList.data);
+};
diff --git a/dappnode/hooks/use-post-telegram-data.ts b/dappnode/hooks/use-post-telegram-data.ts
new file mode 100644
index 00000000..53cde451
--- /dev/null
+++ b/dappnode/hooks/use-post-telegram-data.ts
@@ -0,0 +1,64 @@
+import { useState } from 'react';
+import useDappnodeUrls from './use-dappnode-urls';
+
+const usePostTelegramData = ({
+ userId,
+ botToken,
+}: {
+ userId: number;
+ botToken: string;
+}) => {
+ const { backendUrl } = useDappnodeUrls();
+ const [postTgError, setPostTgError] = useState();
+ const [isLoading, setIsLoading] = useState();
+ const [isSuccess, setIsSuccess] = useState();
+
+ const errorMessages = {
+ default:
+ 'Error while posting the Telegram data. Double-check the provided data.',
+ chatNotFound:
+ 'Error setting Telegram data. Ensure to start the chat with your Bot and check your user ID.',
+ };
+
+ const postTelegramData = async () => {
+ setIsLoading(true);
+ setIsSuccess(false);
+ try {
+ setPostTgError(undefined);
+
+ console.debug(`POSTing telegram data to events indexer API`);
+ const response = await fetch(
+ `${backendUrl}/api/v0/events_indexer/telegramConfig`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ userId, token: botToken }),
+ },
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+
+ if (error.error.includes('Bad Request: chat not found')) {
+ setPostTgError(errorMessages.chatNotFound);
+ } else {
+ setPostTgError(errorMessages.default);
+ }
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ localStorage.setItem('isTgSeen', 'true');
+ setIsLoading(false);
+ setIsSuccess(true);
+ } catch (e) {
+ console.error(`Error POSTing telegram data to events indexer API: ${e}`);
+ setIsLoading(false);
+ }
+ };
+
+ return { postTelegramData, postTgError, isLoading, isSuccess };
+};
+
+export default usePostTelegramData;
diff --git a/dappnode/hooks/use-withdrawn-key-indexes-from-events-api.ts b/dappnode/hooks/use-withdrawn-key-indexes-from-events-api.ts
new file mode 100644
index 00000000..24e404ef
--- /dev/null
+++ b/dappnode/hooks/use-withdrawn-key-indexes-from-events-api.ts
@@ -0,0 +1,64 @@
+import { useLidoSWR } from '@lido-sdk/react';
+import { STRATEGY_LAZY } from 'consts/swr-strategies';
+import { useNodeOperatorId } from 'providers/node-operator-provider';
+import { useCallback } from 'react';
+import { useAccount } from 'shared/hooks';
+import useDappnodeUrls from './use-dappnode-urls';
+import { fetchWithRetry } from 'dappnode/utils/fetchWithRetry';
+
+const parseEvents = (data: any) => {
+ return (data?.withdrawals || []).map((event: any) => ({
+ keyIndex: parseInt(event.KeyIndex, 10),
+ blockNumber: parseInt(event.Raw.blockNumber, 16),
+ }));
+};
+
+const restoreEvents = (events: { keyIndex: number; blockNumber: number }[]) =>
+ events.sort((a, b) => a.blockNumber - b.blockNumber).map((e) => e.keyIndex);
+
+export const useWithdrawnKeyIndexesFromEvents = () => {
+ const { backendUrl } = useDappnodeUrls();
+ const { chainId } = useAccount();
+ const nodeOperatorId = useNodeOperatorId();
+
+ const fetcher = useCallback(async () => {
+ if (!nodeOperatorId) {
+ console.error('Node Operator ID is required to fetch withdrawals');
+ return [];
+ }
+
+ try {
+ console.debug(
+ `Fetching withdrawals for Node Operator ID: ${nodeOperatorId}`,
+ );
+ const url = `${backendUrl}/api/v0/events_indexer/withdrawals_submitted?operatorId=${nodeOperatorId}`;
+ const options = {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' },
+ };
+
+ // Retry logic for 202 status
+ const response = await fetchWithRetry(url, options, 5000);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ const events = parseEvents(data);
+
+ console.debug('Parsed withdrawal events:', events);
+
+ return restoreEvents(events);
+ } catch (e) {
+ console.error(`Error fetching withdrawal events: ${e}`);
+ return [];
+ }
+ }, [backendUrl, nodeOperatorId]);
+
+ return useLidoSWR(
+ ['withdrawn-keys', nodeOperatorId, chainId],
+ nodeOperatorId ? fetcher : null,
+ STRATEGY_LAZY,
+ );
+};
diff --git a/dappnode/import-keys/import-keys-confirm-modal.tsx b/dappnode/import-keys/import-keys-confirm-modal.tsx
new file mode 100644
index 00000000..8d935f86
--- /dev/null
+++ b/dappnode/import-keys/import-keys-confirm-modal.tsx
@@ -0,0 +1,46 @@
+import { Button, Checkbox } from '@lidofinance/lido-ui';
+import Modal from 'dappnode/components/modal';
+import { useState } from 'react';
+
+interface ImportKeysWarningModalProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+ setKeys: (keys: []) => void;
+}
+export default function ImportKeysWarningModal({
+ isOpen,
+ setIsOpen,
+ setKeys,
+}: ImportKeysWarningModalProps) {
+ const [checked, setChecked] = useState(false);
+ const handleClose = () => {
+ setKeys([]);
+ setIsOpen(false);
+ };
+ return (
+
+ Key Import Advisory
+
+ It is crucial that the keys you are about to use are not active or
+ running on any other machine. Running the same keys in multiple
+ locations can lead to conflicts, loss of funds, or security
+ vulnerabilities.
+
+ Please confirm your understanding by checking the box below:
+ setChecked(e.target.checked)}
+ label="I understand it and promise I don't have these keys running somewhere else"
+ checked={checked}
+ />
+
+ setIsOpen(false)}
+ disabled={!checked}
+ >
+ Confirm
+
+
+ );
+}
diff --git a/dappnode/import-keys/keys-input-form.tsx b/dappnode/import-keys/keys-input-form.tsx
new file mode 100644
index 00000000..2396625f
--- /dev/null
+++ b/dappnode/import-keys/keys-input-form.tsx
@@ -0,0 +1,85 @@
+import { Address, Link } from '@lidofinance/lido-ui';
+import { DropZoneContainer, KeystoreFileRow } from './styles';
+import { PasswordInput } from './password-input';
+import useKeystoreDrop from './use-keystore-drop';
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
+import { useFormDepositData } from 'shared/hook-form/form-controller';
+import { SubmitKeysFormInputType } from 'features/create-node-operator/submit-keys-form/context';
+import { useForm } from 'react-hook-form';
+import { useEffect, useState } from 'react';
+import ImportKeysConfirmModal from './import-keys-confirm-modal';
+
+type KeysBrainUploadProps = {
+ label?: string;
+ fieldName?: string;
+ showErrorMessage?: boolean;
+ missingKeys: string[];
+ error: boolean;
+};
+
+export const KeysBrainUpload = ({
+ label = 'Drop keystores JSON files here, or click to select files',
+ showErrorMessage,
+ error: errorProp,
+ missingKeys,
+}: KeysBrainUploadProps) => {
+ const { brainUrl } = useDappnodeUrls();
+ const { getRootProps, keysFiles, removeFile, setKeysFiles } =
+ useKeystoreDrop(missingKeys);
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
+
+ useEffect(() => {
+ if (keysFiles.length === 1) {
+ setIsImportModalOpen(true);
+ }
+ }, [keysFiles]);
+
+ const formObject = useForm({
+ mode: 'onChange',
+ });
+ useFormDepositData(formObject);
+
+ return (
+ <>
+ Upload keystores
+
+ The following pubkeys are not uploaded in the{' '}
+ Staking Brain:
+
+
+ {missingKeys.map((key, i) => (
+
+ ))}
+
+
+ {label}
+
+ {showErrorMessage && (
+ {}
+ )}
+
+ {keysFiles.length > 0 && (
+ <>
+
+
Uploaded Files:
+ {keysFiles.map((file, index) => (
+
+ {file.name}
+ removeFile(file.name)}>x
+
+ ))}
+
+ Password:
+
+ >
+ )}
+ >
+ );
+};
diff --git a/dappnode/import-keys/password-input.tsx b/dappnode/import-keys/password-input.tsx
new file mode 100644
index 00000000..a9e2b93e
--- /dev/null
+++ b/dappnode/import-keys/password-input.tsx
@@ -0,0 +1,33 @@
+import { Input } from '@lidofinance/lido-ui';
+import { EyeIcon } from 'dappnode/notifications/styles';
+import { useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { ReactComponent as EyeOn } from 'assets/icons/eye-on.svg';
+import { ReactComponent as EyeOff } from 'assets/icons/eye-off.svg';
+
+export const PasswordInput = () => {
+ const { setValue, watch } = useFormContext();
+ const password = watch('password');
+
+ const [showValue, setShowValue] = useState(false);
+
+ const toggleShowPass = () => {
+ setShowValue((prev) => !prev);
+ };
+
+ return (
+ {
+ setValue('password', e.target.value);
+ }}
+ rightDecorator={
+
+ {showValue ? : }
+
+ }
+ />
+ );
+};
diff --git a/dappnode/import-keys/styles.ts b/dappnode/import-keys/styles.ts
new file mode 100644
index 00000000..f726f7ad
--- /dev/null
+++ b/dappnode/import-keys/styles.ts
@@ -0,0 +1,42 @@
+import { ThemeName } from '@lidofinance/lido-ui';
+import styled from 'styled-components';
+
+export const DropZoneContainer = styled.div<{ hasError: boolean }>`
+ border: ${({ hasError, theme }) =>
+ hasError
+ ? `1px dashed ${theme.colors.error}`
+ : `1px dashed ${theme.colors.primary}`};
+ padding: 3em;
+ border-radius: 8px;
+ text-align: center;
+ cursor: pointer;
+ font-family: monospace;
+ font-size: ${({ theme }) => theme.fontSizesMap.xxs}px;
+ color: ${({ theme }) => theme.colors.textSecondary};
+
+ background-color: var(--lido-color-controlBg);
+
+ &:hover {
+ border: ${({ hasError, theme }) =>
+ hasError
+ ? `1px solid ${theme.colors.error}`
+ : `1px solid ${theme.colors.primary}`};
+ background-color: ${({ theme }) =>
+ theme.name === ThemeName.light ? '#F6F8FA' : '#252a2e'};
+ }
+`;
+
+export const KeystoreFileRow = styled.div`
+ display: flex;
+ align-items: center;
+
+ > div {
+ cursor: pointer;
+ color: ${({ theme }) => theme.colors.error};
+ font-weight: 700;
+ }
+
+ > p {
+ margin-right: 8px;
+ }
+`;
diff --git a/dappnode/import-keys/use-keystore-drop.ts b/dappnode/import-keys/use-keystore-drop.ts
new file mode 100644
index 00000000..68d3cafb
--- /dev/null
+++ b/dappnode/import-keys/use-keystore-drop.ts
@@ -0,0 +1,79 @@
+import { KeysFile } from 'features/add-keys/add-keys/context';
+import { useCallback, useEffect, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { useFormContext } from 'react-hook-form';
+
+const useKeystoreDrop = (missingKeys: string[]) => {
+ const [keysFiles, setKeysFiles] = useState([]);
+ const { setValue } = useFormContext();
+
+ useEffect(() => {
+ const keystores = [];
+ for (const keystore of keysFiles) {
+ keystores.push(keystore.content);
+ }
+ setValue('keystores', keystores, {
+ shouldValidate: false,
+ shouldDirty: true,
+ });
+ }, [keysFiles, setValue]);
+
+ const onDrop = useCallback(
+ (acceptedFiles: File[]) => {
+ acceptedFiles.forEach((file) => {
+ const reader = new FileReader();
+
+ reader.onloadend = () => {
+ try {
+ const parsedContent = JSON.parse(reader.result as string) as {
+ pubkey: string;
+ };
+
+ if (
+ !parsedContent.pubkey ||
+ !missingKeys.includes(parsedContent.pubkey)
+ ) {
+ alert(
+ `The file "${file.name}" is missing a valid pubkey or it's not in the missing keys list.`,
+ );
+ return;
+ }
+
+ // Remove duplicated keys
+ if (keysFiles.some((f) => f.name === file.name)) {
+ alert(`File "${file.name}" is already uploaded.`);
+ return;
+ }
+
+ setKeysFiles((prevFiles) => [
+ ...prevFiles,
+ { name: file.name, content: parsedContent },
+ ]);
+ } catch (e) {
+ alert(`Failed to parse JSON in file "${file.name}"`);
+ }
+ };
+ reader.readAsText(file);
+ });
+ },
+ [keysFiles, missingKeys],
+ );
+
+ const removeFile = useCallback((fileName: string) => {
+ setKeysFiles((prevFiles) =>
+ prevFiles.filter((file) => file.name !== fileName),
+ );
+ }, []);
+
+ const { getRootProps } = useDropzone({
+ maxFiles: missingKeys.length,
+ onDrop,
+ noKeyboard: true,
+ multiple: true,
+ accept: { 'application/json': ['.json'] },
+ });
+
+ return { getRootProps, keysFiles, removeFile, setKeysFiles };
+};
+
+export default useKeystoreDrop;
diff --git a/dappnode/notifications/input-tg.tsx b/dappnode/notifications/input-tg.tsx
new file mode 100644
index 00000000..ec47ad83
--- /dev/null
+++ b/dappnode/notifications/input-tg.tsx
@@ -0,0 +1,71 @@
+import {
+ ChangeEvent,
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react';
+import { StyledInput } from './styles';
+import { InputAddressProps } from 'shared/components/input-address/types';
+import { ReactComponent as EyeOn } from 'assets/icons/eye-on.svg';
+import { ReactComponent as EyeOff } from 'assets/icons/eye-off.svg';
+import { EyeIcon } from './styles';
+
+interface InputTgProps extends InputAddressProps {
+ isPassword?: boolean;
+}
+
+export const InputTelegram = forwardRef(
+ (
+ {
+ onChange,
+ value,
+ isLocked,
+ rightDecorator,
+ label,
+ isPassword = false,
+ ...props
+ },
+ ref,
+ ) => {
+ const inputRef = useRef(null);
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+
+ const [showValue, setShowValue] = useState(false);
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ useImperativeHandle(ref, () => inputRef.current!, []);
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ const currentValue = e.currentTarget.value;
+ onChange?.(currentValue);
+ },
+ [onChange],
+ );
+
+ const toggleShowPass = () => {
+ setShowValue((prev) => !prev);
+ };
+
+ return (
+ {label}>}
+ ref={inputRef}
+ value={value}
+ onChange={handleChange}
+ rightDecorator={
+ isPassword && (
+
+ {showValue ? : }
+
+ )
+ }
+ disabled={props.disabled || isLocked}
+ spellCheck="false"
+ />
+ );
+ },
+);
diff --git a/dappnode/notifications/notifications-component.tsx b/dappnode/notifications/notifications-component.tsx
new file mode 100644
index 00000000..8f93ad85
--- /dev/null
+++ b/dappnode/notifications/notifications-component.tsx
@@ -0,0 +1,175 @@
+import { FormBlock, FormTitle, Latice, Note, Stack } from 'shared/components';
+import { useCallback, useEffect, useState } from 'react';
+import { Button, Link, Loader } from '@lidofinance/lido-ui';
+import isTelegramUserId from 'dappnode/utils/is-tg-user-id';
+import isTelegramBotToken from 'dappnode/utils/is-tg-bot-token';
+import { BotTokenWrapper, InfoWrapper } from './styles';
+import { InputTelegram } from './input-tg';
+import { dappnodeLidoDocsUrls } from 'dappnode/utils/dappnode-docs-urls';
+import useGetTelegramData from 'dappnode/hooks/use-get-telegram-data';
+import usePostTelegramData from 'dappnode/hooks/use-post-telegram-data';
+import { ReactComponent as EyeOn } from 'assets/icons/eye-on.svg';
+import { ReactComponent as EyeOff } from 'assets/icons/eye-off.svg';
+import { EyeIcon } from './styles';
+import {
+ ErrorWrapper,
+ SuccessWrapper,
+} from 'dappnode/components/text-wrappers';
+import { NotificationsSteps } from './notifications-setup-steps';
+import { LoaderWrapperStyle } from 'shared/navigate/splash/loader-banner/styles';
+
+export const NotificationsComponent = () => {
+ const [newTgUserId, setNewTgUserId] = useState('');
+ const [isUserIdValid, setIsUserIDValid] = useState(false);
+
+ const {
+ telegramId,
+ botToken,
+ getTelegramData,
+ isLoading: isTgGetLoading,
+ } = useGetTelegramData();
+
+ const [newTgBotToken, setNewTgBotToken] = useState('');
+ const [isBotTokenValid, setIsBotTokenValid] = useState(false);
+ const [showCurrentBotToken, setShowCurrentBotToken] = useState(false);
+
+ const { postTelegramData, postTgError, isLoading, isSuccess } =
+ usePostTelegramData({
+ userId: Number(newTgUserId ? newTgUserId : telegramId),
+ botToken: newTgBotToken ? newTgBotToken : botToken || '',
+ });
+
+ const fetchData = useCallback(async () => {
+ await getTelegramData();
+ }, [getTelegramData]);
+
+ useEffect(() => {
+ void fetchData();
+ }, [fetchData]);
+
+ useEffect(() => {
+ setIsUserIDValid(isTelegramUserId(newTgUserId));
+ }, [newTgUserId]);
+
+ useEffect(() => {
+ setIsBotTokenValid(isTelegramBotToken(newTgBotToken));
+ }, [newTgBotToken]);
+
+ const sameAsCurrentUserId = newTgUserId == telegramId;
+ const sameAsCurrentBotToken = newTgBotToken === botToken;
+
+ const userValidationError = () => {
+ if (!newTgUserId) return null;
+ if (!isUserIdValid) return 'Specify a valid user ID';
+ if (sameAsCurrentUserId)
+ return 'Should not be the same as the current user ID';
+ return null;
+ };
+
+ const botTokenValidationError = () => {
+ if (!newTgBotToken) return null;
+ if (!isBotTokenValid) return 'Specify a valid bot token';
+ if (sameAsCurrentBotToken)
+ return 'Should not be the same as the current bot token';
+ return null;
+ };
+
+ const handleSubmit = async () => {
+ if (isUserIdValid || isBotTokenValid) {
+ await postTelegramData();
+ await fetchData();
+ setNewTgUserId('');
+ setNewTgBotToken('');
+ }
+ };
+
+ return (
+ <>
+
+ {!isTgGetLoading && (!telegramId || !botToken) && (
+
+ )}
+ Current Telegram Data:
+
+
+ User ID
+ {telegramId ? telegramId : '-'}
+
+
+
+ Bot Token
+ setShowCurrentBotToken(!showCurrentBotToken)}
+ >
+ {showCurrentBotToken ? : }
+
+
+
+ {botToken
+ ? showCurrentBotToken
+ ? botToken
+ : '*******************************'
+ : '-'}
+
+
+
+ Insert New Telegram Data:
+ setNewTgUserId(newValue)}
+ />
+ setNewTgBotToken(newValue)}
+ />
+ {postTgError && {postTgError} }
+ {isSuccess && (
+
+ Notifications configuration set! Ensure that test alert was sent to
+ your Telegram!
+
+ )}
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ 'Update Telegram Data'
+ )}
+
+
+ You can find a guide on how to get this data in{' '}
+
+ our Documentation
+
+ .
+
+
+ >
+ );
+};
diff --git a/dappnode/notifications/notifications-modal.tsx b/dappnode/notifications/notifications-modal.tsx
new file mode 100644
index 00000000..34a44338
--- /dev/null
+++ b/dappnode/notifications/notifications-modal.tsx
@@ -0,0 +1,46 @@
+import { FC, useEffect, useState } from 'react';
+import Modal, { LinkWrapper } from 'dappnode/components/modal';
+import Link from 'next/link';
+import { PATH } from 'consts/urls';
+import useGetTelegramData from 'dappnode/hooks/use-get-telegram-data';
+
+export const NotificationsModal: FC = () => {
+ const { botToken, telegramId, getTelegramData } = useGetTelegramData();
+ const [isOpen, setIsOpen] = useState(false);
+ useEffect(() => {
+ const fetchData = async () => {
+ await getTelegramData();
+ };
+ void fetchData();
+ }, [getTelegramData]);
+
+ useEffect(() => {
+ if ((!botToken || !telegramId) && !localStorage.getItem('isTgSeen')) {
+ setIsOpen(true);
+ } else {
+ setIsOpen(false);
+ }
+ }, [botToken, telegramId]);
+
+ const handleClose = () => {
+ localStorage.setItem('isTgSeen', 'true');
+ setIsOpen(false);
+ };
+
+ return (
+ <>
+
+ Set up Notifications!
+
+ In order to get notifications about penalties, when your validators
+ exited, and other important events, you need to set up notifications.
+
+
+
+ Navigate here!
+
+
+
+ >
+ );
+};
diff --git a/dappnode/notifications/notifications-page.tsx b/dappnode/notifications/notifications-page.tsx
new file mode 100644
index 00000000..9c00767d
--- /dev/null
+++ b/dappnode/notifications/notifications-page.tsx
@@ -0,0 +1,17 @@
+import { FC } from 'react';
+import { Faq } from 'shared/components';
+
+import { NotificationsComponent } from './notifications-component';
+import { Layout } from 'shared/layout';
+import NotificationsTypes from './notifications-types';
+
+export const NotificationsPage: FC = () => (
+
+
+
+
+
+);
diff --git a/dappnode/notifications/notifications-setup-steps.tsx b/dappnode/notifications/notifications-setup-steps.tsx
new file mode 100644
index 00000000..b380cfaa
--- /dev/null
+++ b/dappnode/notifications/notifications-setup-steps.tsx
@@ -0,0 +1,39 @@
+import { FC } from 'react';
+import styled from 'styled-components';
+
+export const StepsList = styled.ol`
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: start;
+ justify-content: start;
+ font-size: 14px;
+ text-align: left;
+
+ > li::marker {
+ color: var(--lido-color-primary);
+ font-weight: bold;
+ }
+`;
+
+const StepsWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ gap: 10px;
+ width: 100%;
+`;
+
+export const NotificationsSteps: FC = () => {
+ return (
+ <>
+
+
+ Get Telegram user Id
+ Create and get a Telegram Bot token
+ Start the chat with your bot
+
+
+ >
+ );
+};
diff --git a/dappnode/notifications/notifications-types.tsx b/dappnode/notifications/notifications-types.tsx
new file mode 100644
index 00000000..963ed188
--- /dev/null
+++ b/dappnode/notifications/notifications-types.tsx
@@ -0,0 +1,87 @@
+import { Section } from 'shared/components';
+import { AccordionNavigatable } from 'shared/components/accordion-navigatable';
+import { NotificationsList } from './styles';
+
+const avaliableNotifications = {
+ Exits: [
+ {
+ title: 'Validator requires exit 🚨',
+ value:
+ 'Your validator is automatically requested to exit due to certain conditions.',
+ },
+ {
+ title: 'Validator failed to exit, manual exit required 🚪',
+ value:
+ 'Your validator failed to exit automatically and requires manual intervention.',
+ },
+ {
+ title: 'Validator successfully exited 🚪',
+ value:
+ 'Your validator has successfully entered the exit queue without requiring manual action.',
+ },
+ ],
+ Performance: [
+ {
+ title: 'Operator in status stuck in latest report 🚨',
+ value:
+ "An operator is in a 'stuck' state for the specified epoch range. Performance should be checked.",
+ },
+ {
+ title: 'Operator bad performance in latest report 🚨',
+ value:
+ "The operator's performance was below the acceptable threshold during the specified epoch range.",
+ },
+ {
+ title: 'Operator good performance in latest report ✅',
+ value:
+ "The operator's performance exceeded the threshold during the specified epoch range.",
+ },
+ ],
+ Relays: [
+ {
+ title: 'Blacklisted relay 🚨',
+ value:
+ 'A blacklisted relay is currently being used, which is not allowed.',
+ },
+ {
+ title: 'Missing mandatory relay ⚠️',
+ value:
+ 'No mandatory relays are currently in use. Add at least one mandatory relay in the stakers UI.',
+ },
+ ],
+ Others: [
+ {
+ title: 'New distribution log updated 📦',
+ value:
+ 'A new distribution log has been updated and will be used for validator performance visualization.',
+ },
+ {
+ title: 'Execution client does not have logs receipts 🚨',
+ value:
+ 'The execution client is missing log receipts, preventing event scanning. Update your configuration or switch to a compatible client.',
+ },
+ {
+ title: 'CsModule events notifications 📋',
+ value:
+ 'Covers updates on rewards, penalties, new keys, and manager address proposals for the Lido CSModule smart contract.',
+ },
+ ],
+};
+
+export default function NotificationsTypes() {
+ return (
+
+ {Object.entries(avaliableNotifications).map(([key, value]) => (
+
+
+ {value.map((notification, i) => (
+
+ {notification.title} - {notification.value}
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/dappnode/notifications/styles.ts b/dappnode/notifications/styles.ts
new file mode 100644
index 00000000..b775a627
--- /dev/null
+++ b/dappnode/notifications/styles.ts
@@ -0,0 +1,39 @@
+import styled from 'styled-components';
+import { Input } from '@lidofinance/lido-ui';
+import { TitledAddressStyle } from 'shared/components/titled-address/style';
+
+export const InfoWrapper = styled(TitledAddressStyle)`
+ padding: 22px 16px;
+ width: 100%;
+ > p {
+ font-weight: lighter;
+ font-size: 14px;
+ text-align: right;
+ }
+`;
+
+export const BotTokenWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: 12px;
+`;
+
+export const StyledInput = styled(Input)`
+ width: 100%;
+ input + span {
+ overflow: visible;
+ }
+`;
+
+export const EyeIcon = styled.div`
+ cursor: pointer;
+ display: flex;
+ margin-right: 5px;
+`;
+
+export const NotificationsList = styled.ul`
+ font-size: 14px;
+ > li {
+ margin-bottom: 20px;
+ }
+`;
diff --git a/dappnode/performance/components/performance-card.tsx b/dappnode/performance/components/performance-card.tsx
new file mode 100644
index 00000000..4f44554f
--- /dev/null
+++ b/dappnode/performance/components/performance-card.tsx
@@ -0,0 +1,37 @@
+import { Text } from '@lidofinance/lido-ui';
+import { Tooltip } from '@lidofinance/lido-ui';
+import {
+ CardTooltipWrapper,
+ PerformanceCardStyle,
+ TooltipIcon,
+} from './styles';
+
+interface PerformanceCardProps {
+ title: string;
+ tooltip?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export const PerformanceCard = ({
+ title,
+ tooltip,
+ children,
+}: PerformanceCardProps) => {
+ return (
+
+
+ {tooltip && (
+
+ ?
+
+ )}
+
+ {title}
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/dappnode/performance/components/performance-table.tsx b/dappnode/performance/components/performance-table.tsx
new file mode 100644
index 00000000..cbaf2079
--- /dev/null
+++ b/dappnode/performance/components/performance-table.tsx
@@ -0,0 +1,80 @@
+import { Tbody, Td, Text, Th, Thead, Tr } from '@lidofinance/lido-ui';
+import { FC } from 'react';
+import { BeaconchainPubkeyLink } from 'shared/components';
+import { AddressRow, TableStyle, TooltipIcon } from './styles';
+import { ValidatorStats } from '../types';
+import { Tooltip } from '@lidofinance/lido-ui';
+
+interface PerformanceTableProps {
+ data: ValidatorStats[];
+ threshold: number;
+ offset?: number;
+}
+
+const tooltips = {
+ validator: "Your validator's index",
+ attestations:
+ 'Shows the number of attestations your validator has included compared to the number of attestations assigned to it for the selected range.',
+ efficiency:
+ "Shows your validator's attestation rate compared to Lido's threshold. Green indicates it's above the average; red means it's below.",
+};
+
+export const PerformanceTable: FC = ({
+ data,
+ threshold,
+}) => (
+
+
+
+
+
+ Validator ?
+
+
+
+
+ Attestations ?
+
+
+
+
+ Efficiency ?
+
+
+
+
+
+
+ {data?.map((validator, _) => (
+
+
+
+
+ {validator.index}
+
+
+
+
+
+
+ {validator.attestations.included +
+ ' / ' +
+ validator.attestations.assigned}
+
+
+
+ threshold ? 'success' : 'error'}
+ >
+ {validator.efficiency
+ ? (Number(validator.efficiency.toFixed(4)) * 100).toFixed(2) +
+ ' %'
+ : '-'}
+
+
+
+ ))}
+
+
+);
diff --git a/dappnode/performance/components/range-selector.tsx b/dappnode/performance/components/range-selector.tsx
new file mode 100644
index 00000000..c156db5e
--- /dev/null
+++ b/dappnode/performance/components/range-selector.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+import { Dropdown, RangeDropdown, RangeWrapper } from './styles';
+import { Range } from '../types';
+import { ReactComponent as RoundedArrowIcon } from 'assets/icons/down-arrow.svg';
+
+interface RangeSelectorProps {
+ setRange: (value: Range) => void;
+ range: Range;
+ chainId: number;
+}
+
+export const RangeSelector = ({
+ chainId,
+ range,
+ setRange,
+}: RangeSelectorProps) => {
+ const [showDropdown, setShowDropdown] = useState(false);
+ const dropDownOptions: Range[] =
+ chainId !== 1
+ ? ['week', 'month', 'year', 'ever']
+ : ['month', 'year', 'ever'];
+
+ const handleRangeSelect = (value: Range) => {
+ setRange(value);
+ setShowDropdown(false);
+ };
+
+ return (
+
+ RANGE:
+ setShowDropdown(!showDropdown)}
+ >
+ {range.substring(0, 1).toUpperCase() + range.substring(1)}
+
+ {showDropdown && (
+
+ {dropDownOptions.map((r) => (
+ handleRangeSelect(r)}>
+ {r.substring(0, 1).toUpperCase() + r.substring(1)}
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/dappnode/performance/components/styles.ts b/dappnode/performance/components/styles.ts
new file mode 100644
index 00000000..a39fe171
--- /dev/null
+++ b/dappnode/performance/components/styles.ts
@@ -0,0 +1,227 @@
+import { Block, Table } from '@lidofinance/lido-ui';
+import { StackStyle } from 'shared/components/stack/style';
+import styled from 'styled-components';
+
+/**
+ * TABLE STYLES
+ */
+export const ViewKeysBlock = styled(Block)`
+ display: flex;
+ gap: ${({ theme }) => theme.spaceMap.md}px;
+ flex-direction: column;
+`;
+
+export const TableStyle = styled(Table)`
+ margin: -32px -32px;
+
+ thead tr::before,
+ thead tr::after,
+ th {
+ border-top: none;
+ }
+
+ th {
+ padding: 24px 8px 16px 8px;
+ min-width: 40px;
+ }
+ tr {
+ padding: 0;
+ }
+
+ td,
+ th {
+ padding-left: 0;
+ padding-right: 0;
+ }
+
+ td {
+ padding: 15px 0;
+ }
+
+ th > div {
+ text-align: center;
+ }
+
+ td {
+ border-bottom: none;
+ }
+
+ tbody tr:nth-child(odd) {
+ background-color: var(--lido-color-accentControlBg);
+ }
+
+ td > div > p {
+ text-align: center;
+ }
+`;
+
+export const TooltipIcon = styled.span`
+ color: var(--lido-color-textSecondary);
+ opacity: 0.5;
+ border: solid 1px var(--lido-color-textSecondary);
+ font-size: 10px;
+ border-radius: 100%;
+ padding: 0 4px;
+ margin-left: 2px;
+`;
+
+export const AddressRow = styled(StackStyle).attrs({ $gap: 'xs' })`
+ align-items: center;
+ justify-content: center;
+`;
+
+/**
+ * RANGE SELECTOR STYLES
+ */
+export const SelectedRangeWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: 5px;
+ align-items: center;
+ > div {
+ background-color: var(--lido-color-foreground);
+ padding: 4px 8px;
+ border-radius: 4px;
+ }
+`;
+
+export const RangeWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 15px;
+ color: var(--lido-color-textSecondary);
+ font-size: 14px;
+ font-weight: 400;
+ margin-top: 10px;
+`;
+
+export const RangeDropdown = styled.div`
+ min-width: 70px;
+ background-color: var(--lido-color-foreground);
+ padding: 8px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 10px;
+
+ svg {
+ transition: transform 0.3s ease-in-out;
+ transform: ${({ isOpen }: { isOpen: boolean }) =>
+ isOpen ? 'rotate(180deg)' : 'rotate(0deg)'};
+ }
+`;
+
+export const Dropdown = styled.div`
+ position: absolute;
+ top: 100%;
+ left: 0;
+ background-color: var(--lido-color-foreground);
+ border: 1px solid var(--lido-color-border);
+ border-radius: 8px;
+ margin-top: 8px;
+ padding: 8px 0;
+ width: 100%;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ z-index: 10;
+
+ div {
+ padding: 8px 16px;
+ cursor: pointer;
+ &:hover {
+ opacity: 0.6;
+ }
+ }
+`;
+
+/**
+ * CHART STYLES
+ */
+export const ChartSectionWrapper = styled.div`
+ width: 150%;
+`;
+
+export const ChartWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+`;
+
+// Generate unique colors for validator lines
+const colorPalette = ['#82ca9d', '#ff7300', '#8884d8', '#ffc658', '#0088FE'];
+export const getColor = (index: number) =>
+ colorPalette[index % colorPalette.length];
+
+export const LegendWrapper = styled.div`
+ display: flex;
+ gap: 20px;
+ flex-direction: column;
+ height: 400px;
+ overflow-y: auto;
+`;
+
+export const LegendItem = styled.div<{ color: string }>`
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: ${({ color }) => color};
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ background-color: ${({ color }) => color};
+ border-radius: 50%;
+ }
+
+ :hover {
+ opacity: 0.8;
+ }
+`;
+
+export const ChartControlsWrapper = styled.div`
+ color: var(--lido-color-textSecondary);
+ margin: 20px 0px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+`;
+export const ChartControls = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: 5px;
+ width: 50%;
+ > input {
+ width: 100%;
+ }
+`;
+
+export const NoteWrapper = styled.div`
+ width: 70%;
+`;
+
+/**
+ * PERFORMANCE CARDS STYLES
+ */
+
+export const PerformanceCardStyle = styled(StackStyle).attrs({ $gap: 'sm' })`
+ border-radius: ${({ theme }) => theme.borderRadiusesMap.md}px;
+ padding: 16px 22px;
+ background: var(--lido-color-foreground);
+ display: flex;
+ flex-direction: column;
+ position: relative;
+`;
+
+export const CardTooltipWrapper = styled.div`
+ position: absolute;
+ top: 6px;
+ right: 10px;
+`;
diff --git a/dappnode/performance/index.ts b/dappnode/performance/index.ts
new file mode 100644
index 00000000..2f885303
--- /dev/null
+++ b/dappnode/performance/index.ts
@@ -0,0 +1 @@
+export * from './performance-page';
diff --git a/dappnode/performance/performance-cards-section.tsx b/dappnode/performance/performance-cards-section.tsx
new file mode 100644
index 00000000..f1150a97
--- /dev/null
+++ b/dappnode/performance/performance-cards-section.tsx
@@ -0,0 +1,47 @@
+import { Stack } from 'shared/components';
+import { PerformanceCard } from './components/performance-card';
+import { useGetNextReport } from 'dappnode/hooks/use-get-next-report';
+import { Link, Loader } from '@lidofinance/lido-ui';
+import { dappnodeLidoDocsUrls } from 'dappnode/utils/dappnode-docs-urls';
+import { useGetPendingReports } from 'dappnode/hooks/use-get-pending-reports';
+import { LoaderWrapperStyle } from 'shared/navigate/splash/loader-banner/styles';
+
+export const PerformanceCardsSection = () => {
+ const daysUntilNextReport = useGetNextReport();
+ const { pendingReports, isLoading } = useGetPendingReports();
+
+ return (
+
+
+ {daysUntilNextReport} {daysUntilNextReport === 1 ? 'day' : 'days'}
+
+
+ This represents the number of reports yet to be processed. If you
+ have active validators, it may include the performance of them.
+ These reports will be parsed automatically within the next hours.
+ Learn more about it in our{' '}
+
+ our Documentation
+
+
+ }
+ >
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {pendingReports} {pendingReports === 1 ? 'report' : 'reports'}
+
+ )}
+
+
+ );
+};
diff --git a/dappnode/performance/performance-chart-section.tsx b/dappnode/performance/performance-chart-section.tsx
new file mode 100644
index 00000000..652e67d2
--- /dev/null
+++ b/dappnode/performance/performance-chart-section.tsx
@@ -0,0 +1,261 @@
+import { FC, useState, useEffect } from 'react';
+import {
+ LineChart,
+ Line,
+ CartesianGrid,
+ XAxis,
+ YAxis,
+ Tooltip as ChartTooltip,
+ ResponsiveContainer,
+} from 'recharts';
+import {
+ LegendWrapper,
+ LegendItem,
+ getColor,
+ ChartControls,
+ ChartControlsWrapper,
+ SelectedRangeWrapper,
+ ChartWrapper,
+ ChartSectionWrapper,
+ TooltipIcon,
+} from './components/styles';
+import { Range } from './types';
+import { WhenLoaded } from 'shared/components';
+import { Link, Tooltip, Text } from '@lidofinance/lido-ui';
+
+interface PerformanceChartProps {
+ isLoading: boolean;
+ thresholdsByEpoch: any[];
+ range: Range;
+ chainId: number;
+}
+
+export const PerformanceChartSection: FC = ({
+ isLoading,
+ thresholdsByEpoch,
+ range,
+ chainId,
+}) => {
+ const [reportsRange, setReportsRange] = useState(2); // Default value for 'week'
+ const [visibleValidators, setVisibleValidators] = useState([]);
+
+ // Since the minimum granularity in holesky is 'week' and in mainnet 'month', do not display the slider controls. Also, if there are less than 3 reports, do not display the slider controls
+ const displayChartControls =
+ thresholdsByEpoch.length > 2
+ ? chainId === 1 && range === 'month'
+ ? false
+ : chainId !== 1 && range === 'week'
+ ? false
+ : true
+ : false;
+
+ // Sets the report range based on the network (since reports are distrubuted differently) and also checks if db has less reports than available in range time
+ useEffect(() => {
+ if (range === 'month') {
+ setReportsRange(
+ chainId === 1
+ ? 2
+ : thresholdsByEpoch.length < 4
+ ? thresholdsByEpoch.length
+ : 4,
+ );
+ } else if (range === 'week') {
+ setReportsRange(2);
+ } else if (range === 'year') {
+ setReportsRange(
+ chainId === 1
+ ? thresholdsByEpoch.length < 12
+ ? thresholdsByEpoch.length
+ : 12
+ : thresholdsByEpoch.length < 52
+ ? thresholdsByEpoch.length
+ : 52,
+ );
+ } else {
+ setReportsRange(thresholdsByEpoch.length);
+ }
+ }, [chainId, range, thresholdsByEpoch]);
+
+ // Initialize `visibleValidators` to include all validator keys by default
+ useEffect(() => {
+ const allValidators = Array.from(
+ thresholdsByEpoch.reduce>((keys, entry) => {
+ Object.keys(entry).forEach((key) => {
+ if (key !== 'name' && key !== 'lidoThreshold') {
+ keys.add(key);
+ }
+ });
+ return keys;
+ }, new Set()),
+ );
+ setVisibleValidators(allValidators);
+ }, [thresholdsByEpoch]);
+
+ const visibleData = thresholdsByEpoch.slice(-reportsRange);
+
+ const handleRangeChange = (event: React.ChangeEvent) => {
+ const newRange = Math.max(2, parseInt(event.target.value, 10)); // Ensure the value is not below 2 since at least 2 reports needed to display the chart
+ setReportsRange(newRange);
+ };
+
+ const handleValidatorToggle = (validatorKey: string) => () => {
+ if (visibleValidators.includes(validatorKey)) {
+ setVisibleValidators((prev) =>
+ prev.filter((visibleValidator) => visibleValidator !== validatorKey),
+ );
+ } else {
+ setVisibleValidators((prev) => [...prev, validatorKey]);
+ }
+ };
+
+ return (
+ <>
+
+ Node Operator Efficiency vs Lido Average Efficiency
+
+
+
+
+
+
+
+ {/* Always render the line for lidoThreshold */}
+
+
+ {/* Render a line for each visibleValidator */}
+ {thresholdsByEpoch.length > 0 &&
+ Array.from(
+ thresholdsByEpoch.reduce>((keys, entry) => {
+ Object.keys(entry).forEach((key) => {
+ if (key !== 'name' && key !== 'lidoThreshold') {
+ keys.add(key);
+ }
+ });
+ return keys;
+ }, new Set()),
+ ).map(
+ (validatorKey, index) =>
+ visibleValidators.includes(validatorKey) && (
+
+ ),
+ )}
+
+
+
+
+
+
+
+
+
+
+ Lido Threshold
+ {thresholdsByEpoch.length > 0 &&
+ Array.from(
+ thresholdsByEpoch.reduce>((keys, entry) => {
+ Object.keys(entry).forEach((key) => {
+ if (key !== 'name' && key !== 'lidoThreshold') {
+ keys.add(key);
+ }
+ });
+ return keys;
+ }, new Set()),
+ ).map((validatorKey, index) => (
+
+ {validatorKey}
+
+ ))}
+
+
+
+ {displayChartControls && (
+
+
+
+ A frame is the period of time between the Lido CSM
+ reports. In each report, a new Lido threshold is
+ propagated. For more information, check out{' '}
+
+ Lido's Documentation
+
+ .
+ >
+ }
+ >
+
+ Frames to Display? :{' '}
+
+
+
+ {reportsRange}
+
+
+
+ 2
+
+ {thresholdsByEpoch.length}
+
+
+
+ )}
+
+
+ >
+ );
+};
diff --git a/dappnode/performance/performance-page.tsx b/dappnode/performance/performance-page.tsx
new file mode 100644
index 00000000..eb569065
--- /dev/null
+++ b/dappnode/performance/performance-page.tsx
@@ -0,0 +1,49 @@
+import { FC, useState } from 'react';
+import { Faq } from 'shared/components';
+import { Layout } from 'shared/layout';
+import { useGetPerformanceByRange } from 'dappnode/hooks/use-get-performance-by-range';
+import { Range } from './types';
+
+import { PerformanceTableSection } from './performance-table-section';
+import { PerformanceChartSection } from './performance-chart-section';
+import { useAccount } from 'shared/hooks';
+import { RangeSelector } from './components/range-selector';
+import { PerformanceCardsSection } from './performance-cards-section';
+import { getConfig } from 'config';
+
+export const SummaryTablePage: FC = () => {
+ const { chainId } = useAccount();
+ const { defaultChain } = getConfig();
+ const [range, setRange] = useState('ever');
+ const { isLoading, validatorsStats, threshold, thresholdsByEpoch } =
+ useGetPerformanceByRange(range);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/dappnode/performance/performance-table-section.tsx b/dappnode/performance/performance-table-section.tsx
new file mode 100644
index 00000000..1411e19e
--- /dev/null
+++ b/dappnode/performance/performance-table-section.tsx
@@ -0,0 +1,33 @@
+import { FC } from 'react';
+import { WhenLoaded, Section } from 'shared/components';
+import { ViewKeysBlock } from './components/styles';
+import { PerformanceTable } from './components/performance-table';
+import { ValidatorStats } from './types';
+
+interface PerformanceTableProps {
+ isLoading: boolean;
+ validatorsStats: ValidatorStats[];
+ threshold: number;
+}
+
+export const PerformanceTableSection: FC = ({
+ isLoading,
+ validatorsStats,
+ threshold,
+}) => {
+ return (
+
+ );
+};
diff --git a/dappnode/performance/types.ts b/dappnode/performance/types.ts
new file mode 100644
index 00000000..cdaa725d
--- /dev/null
+++ b/dappnode/performance/types.ts
@@ -0,0 +1,8 @@
+export type Range = 'week' | 'month' | 'year' | 'ever';
+
+export interface ValidatorStats {
+ index: number;
+ attestations: { assigned: number; included: number };
+ // proposals: number;
+ efficiency: number;
+}
diff --git a/dappnode/starter-pack/step-wrapper.tsx b/dappnode/starter-pack/step-wrapper.tsx
new file mode 100644
index 00000000..cbd36d8c
--- /dev/null
+++ b/dappnode/starter-pack/step-wrapper.tsx
@@ -0,0 +1,14 @@
+import { FC, PropsWithChildren } from 'react';
+import { Number, StepContent, StepTitle, StepWrapper } from './styles';
+
+export const Step: FC<
+ PropsWithChildren<{ stepNum: string; title: string }>
+> = ({ stepNum: number, title, children }) => {
+ return (
+
+ {number}
+ {title}
+ {children}
+
+ );
+};
diff --git a/dappnode/starter-pack/steps.tsx b/dappnode/starter-pack/steps.tsx
new file mode 100644
index 00000000..798c93dc
--- /dev/null
+++ b/dappnode/starter-pack/steps.tsx
@@ -0,0 +1,424 @@
+import { MATOMO_CLICK_EVENTS_TYPES } from 'consts/matomo-click-events';
+import NextLink from 'next/link';
+import {
+ Dispatch,
+ FC,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import { MatomoLink, Note } from 'shared/components';
+import { Step2InfraRow, InfraInstalledLabel, ButtonsRow } from './styles';
+import { Step } from './step-wrapper';
+import { Button, Link } from '@lidofinance/lido-ui';
+import { CONSTANTS_BY_NETWORK } from 'consts/csm-constants';
+import { PATH } from 'consts/urls';
+import { trackMatomoEvent } from 'utils';
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
+import { InputTelegram } from 'dappnode/notifications/input-tg';
+import { dappnodeLidoDocsUrls } from 'dappnode/utils/dappnode-docs-urls';
+import isTelegramUserID from 'dappnode/utils/is-tg-user-id';
+import isTelegramBotToken from 'dappnode/utils/is-tg-bot-token';
+import { useChainId } from 'wagmi';
+import usePostTelegramData from 'dappnode/hooks/use-post-telegram-data';
+import useApiBrain from 'dappnode/hooks/use-brain-keystore-api';
+import { useGetInfraStatus } from 'dappnode/hooks/use-get-infra-status';
+import useGetTelegramData from 'dappnode/hooks/use-get-telegram-data';
+import { Loader } from '@lidofinance/lido-ui';
+import { ErrorWrapper } from 'dappnode/components/text-wrappers';
+import { LoaderWrapperStyle } from 'shared/navigate/splash/loader-banner/styles';
+import { NotificationsSteps } from 'dappnode/notifications/notifications-setup-steps';
+import { CHAINS } from '@lido-sdk/constants';
+import useGetRelaysData from 'dappnode/hooks/use-get-relays-data';
+
+export const Steps: FC = () => {
+ const StepsTitles: Record = {
+ 1: 'Have Tokens for Bond',
+ 2: 'Set Up your node',
+ 3: 'Set up Notifications',
+ 4: 'Generate Keys',
+ };
+
+ const [step, setStep] = useState(1);
+
+ return step === 1 ? (
+
+ ) : step === 2 ? (
+
+ ) : step === 3 ? (
+
+ ) : step === 4 ? (
+
+ ) : (
+ 'Error: Please, reload the page!'
+ );
+};
+
+interface StepsProps {
+ step: number;
+ title: string;
+ setStep: Dispatch>;
+}
+
+const Step1: FC = ({ step, title, setStep }: StepsProps) => (
+ <>
+
+
+ Bond is a security collateral submitted by Node Operators
+ before uploading validator keys, covering potential losses from
+ inappropriate actions.
+
+
+
+
+ Learn more
+
+
+ {
+ setStep((prevState) => prevState + 1);
+ }}
+ >
+ Next
+
+ >
+);
+
+const Step2: FC = ({ step, title, setStep }: StepsProps) => {
+ const { ECStatus, CCStatus, isECLoading, isCCLoading } = useGetInfraStatus();
+ const { error: brainError, isLoading: brainLoading } = useApiBrain();
+ const { isMEVRunning, isLoading: relaysLoading } = useGetRelaysData();
+
+ const isECSynced: boolean = ECStatus === 'SYNCED';
+ const isCCSynced: boolean = CCStatus === 'SYNCED';
+
+ const isSignerInstalled: boolean = brainError ? false : true;
+
+ const isMEVInstalled: boolean = isMEVRunning ?? false;
+
+ const isNextBtnDisabled: boolean =
+ !isECSynced || !isCCSynced || !isSignerInstalled || !isMEVInstalled;
+
+ const { stakersUiUrl: stakersUrl } = useDappnodeUrls();
+
+ const chainId = useChainId();
+ return (
+ <>
+
+
+ In order to be a Node Operator you must have a synced{' '}
+ {CHAINS[chainId]} Node and run MEV Boost.
+
+
+ Execution Client
+ {'->'}
+
+ {isECLoading ? (
+
+
+
+ ) : (
+
+ {ECStatus}
+
+ )}
+
+
+
+ Consensus Client
+ {'->'}
+
+
+ {isCCLoading ? (
+
+
+
+ ) : (
+
+ {CCStatus}
+
+ )}
+
+
+
+ Web3signer
+ {'->'}
+
+ {brainLoading ? (
+
+
+
+ ) : (
+
+ {isSignerInstalled ? 'Installed' : 'Not installed'}
+
+ )}
+
+
+
+ MEV Boost
+ {'->'}
+
+ {relaysLoading ? (
+
+
+
+ ) : (
+
+ {isMEVInstalled ? 'Installed' : 'Not installed'}
+
+ )}
+
+
+ {!isECSynced ||
+ (!isCCSynced && (
+
+
+ You must have a synced {CHAINS[chainId]} Node and run MEV Boost.
+
+
+ ))}
+ {!!brainError && (
+
+ You must have Web3Signer installed.
+
+ )}
+ Set up your node{' '}
+
+
+
+ {
+ setStep((prevState) => prevState - 1);
+ }}
+ >
+ Back
+
+
+ {
+ setStep((prevState) => prevState + 1);
+ }}
+ disabled={
+ isNextBtnDisabled || isECLoading || isCCLoading || brainLoading
+ }
+ >
+ {'Next'}
+
+
+ >
+ );
+};
+
+const Step3: FC = ({ step, title, setStep }: StepsProps) => {
+ const {
+ botToken,
+ telegramId,
+ getTelegramData,
+ isLoading: isTgGetLoading,
+ } = useGetTelegramData();
+
+ const [tgUserId, setTgUserId] = useState('');
+ const [tgBotToken, setTgBotToken] = useState('');
+
+ const [isUserIdValid, setIsUserIDValid] = useState(false);
+ const [isBotTokenValid, setIsBotTokenValid] = useState(false);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ await getTelegramData();
+ };
+
+ void fetchData();
+ }, [getTelegramData]);
+
+ useEffect(() => {
+ setTgUserId(telegramId || '');
+ setTgBotToken(botToken || '');
+ }, [telegramId, botToken]);
+
+ const { postTelegramData, postTgError, isLoading, isSuccess } =
+ usePostTelegramData({
+ userId: Number(tgUserId),
+ botToken: tgBotToken,
+ });
+
+ useEffect(() => {
+ setIsUserIDValid(isTelegramUserID(tgUserId));
+ }, [tgUserId]);
+
+ useEffect(() => {
+ setIsBotTokenValid(isTelegramBotToken(tgBotToken));
+ }, [tgBotToken]);
+
+ const userValidationError = () => {
+ if (!tgUserId) return null;
+ if (!isUserIdValid) return 'Specify a valid user ID';
+ return null;
+ };
+
+ const botTokenValidationError = () => {
+ if (!tgBotToken) return null;
+ if (!isBotTokenValid) return 'Specify a valid bot token';
+ return null;
+ };
+
+ const handleNext = async () => {
+ if (isBotTokenValid && isUserIdValid) {
+ await postTelegramData();
+ } else {
+ setStep((prevState) => prevState + 1);
+ }
+ };
+
+ useEffect(() => {
+ if (isSuccess) {
+ void setStep((prevState) => prevState + 1);
+ }
+ }, [isSuccess, setStep]);
+ return (
+ <>
+
+
+ Dappnode's Notification system allows you to receive alerts
+ regardidng your node and validators directly to your telegram.
+
+ Both inputs are needed to set up alerts ensuring your privacy.
+ {!isTgGetLoading && (!telegramId || !botToken) && (
+
+ )}
+ setTgUserId(newValue)}
+ />
+ setTgBotToken(newValue)}
+ />
+
+
+ We highly recommend setting up these notifications to quickly detect
+ underperformance and avoid penalties.
+
+
+
+ You can find a guide on how to set notifications in{' '}
+
+ our Documentation
+
+ .
+
+ {postTgError && {postTgError} }
+
+ {
+ setStep((prevState) => prevState - 1);
+ }}
+ >
+ Back
+
+
+
+ {tgUserId || tgBotToken ? (
+ isLoading ? (
+
+
+
+ ) : (
+ 'Next'
+ )
+ ) : (
+ 'Skip'
+ )}
+
+
+ >
+ );
+};
+const Step4: FC = ({ step, title, setStep }: StepsProps) => {
+ const chainId = useChainId() as keyof typeof CONSTANTS_BY_NETWORK;
+
+ const withdrawalByAddres =
+ CONSTANTS_BY_NETWORK[chainId]?.withdrawalCredentials;
+
+ const handleClick = useCallback(() => {
+ trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.starterPackCreateNodeOperator);
+ }, []);
+ return (
+ <>
+
+
+ In order to run a validator, you need to generate the necessary
+ keystores and deposit data.
+
+
+
+ Set {withdrawalByAddres} as the withdrawal address while
+ generating the keystores. This is the Lido Withdrawal Vault on Holesky{' '}
+
+
+ Prepare your deposit data (.json file) for submitting your keys in the
+ next step.
+
+
+ Just generate the keys, do NOT execute the deposits.
+
+
+
+ You can find a guide on how to generate keys in{' '}
+ our Documentation.
+
+
+ {
+ setStep((prevState) => prevState - 1);
+ }}
+ >
+ Back
+
+
+
+ Create Node Operator
+
+
+ >
+ );
+};
diff --git a/dappnode/starter-pack/styles.ts b/dappnode/starter-pack/styles.ts
new file mode 100644
index 00000000..4f747dfd
--- /dev/null
+++ b/dappnode/starter-pack/styles.ts
@@ -0,0 +1,143 @@
+import { Block, ThemeName } from '@lidofinance/lido-ui';
+import styled from 'styled-components';
+
+export const Header = styled.h1`
+ font-size: 48px; // @style
+ line-height: 52px; // @style
+ font-weight: 600;
+`;
+
+export const Heading = styled.header`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ h2 {
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 28px;
+ }
+
+ p {
+ color: var(--lido-color-textSecondary);
+ font-size: 12px;
+ line-height: 20px;
+ }
+`;
+
+export const ContentWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spaceMap.md}px;
+
+ text-align: left;
+ color: var(--lido-color-text);
+ font-size: ${({ theme }) => theme.fontSizesMap.xs}px;
+ line-height: ${({ theme }) => theme.fontSizesMap.xl}px;
+
+ ul {
+ color: var(--lido-color-primary);
+ padding-inline-start: 22px;
+ }
+`;
+
+export const BlockStyled = styled(Block)`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spaceMap.md}px;
+ border-radius: 32px; // @style
+
+ text-align: center;
+ color: var(--lido-color-text);
+ font-size: ${({ theme }) => theme.fontSizesMap.xxs}px;
+ line-height: ${({ theme }) => theme.fontSizesMap.lg}px;
+`;
+
+export const StepWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-height: 450px;
+ padding: 16px 40px; // @style
+ border-radius: 10px;
+ background: ${({ theme }) =>
+ theme.name === ThemeName.light ? '#eff2f6' : '#3e3d46'};
+`;
+
+export const Number = styled.div`
+ display: flex;
+ width: 60px;
+ height: 60px;
+ margin-bottom: 30px;
+ justify-content: center;
+ align-items: center;
+ border-radius: 100%;
+ background: var(--lido-color-foreground);
+
+ color: var(--lido-color-textSecondary);
+ font-size: 32px;
+ font-weight: 700;
+`;
+export const StepTitle = styled.h3`
+ font-size: 20px;
+ font-weight: 500;
+`;
+
+export const StepContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex: 1 0 40%;
+ align-items: center;
+ text-align: center;
+ justify-content: space-evenly;
+ gap: 10px;
+ padding-top: 10px;
+
+ p {
+ color: var(--lido-color-textSecondary);
+ font-size: 15px;
+ font-weight: 400;
+ text-align: left;
+ width: 100%;
+ line-height: 20px;
+ }
+`;
+
+export const Step2InfraRow = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+
+ ${({ theme }) => theme.mediaQueries.md} {
+ width: 100%;
+ }
+ & > p {
+ flex: 1;
+ width: 100%;
+ font-size: 14px;
+ text-align: center;
+ }
+`;
+
+export const InfraInstalledLabel = styled.span<{ $isInstalled: boolean }>`
+ color: var(
+ ${({ $isInstalled }) =>
+ $isInstalled ? '--lido-color-success' : '--lido-color-error'}
+ );
+ font-weight: 600;
+`;
+
+export const ButtonsRow = styled.div`
+ width: 100%;
+
+ display: flex;
+ flex-direction: row;
+ gap: ${({ theme }) => theme.spaceMap.md}px;
+
+ Button,
+ .button-wrapper {
+ flex: 1 1 0%;
+ }
+`;
diff --git a/dappnode/status/InfraItem.tsx b/dappnode/status/InfraItem.tsx
new file mode 100644
index 00000000..ec24e34d
--- /dev/null
+++ b/dappnode/status/InfraItem.tsx
@@ -0,0 +1,48 @@
+import { FC } from 'react';
+import { TitleStyled, ItemStyled, SubtitleStyled } from './styles';
+import { Loader, Tooltip } from '@lidofinance/lido-ui';
+import { StatusChip } from 'shared/components';
+import { LoaderWrapperStyle } from 'shared/navigate/splash/loader-banner/styles';
+import { INFRA_STATUS } from './types';
+
+export type InfraItemProps = {
+ title: string;
+ subtitle: string;
+ tooltip?: string;
+ status: INFRA_STATUS;
+ isLoading: boolean;
+};
+
+export const InfraItem: FC = ({
+ title,
+ tooltip,
+ subtitle,
+ status,
+ isLoading,
+}) => {
+ const body = (
+
+
+ {title}
+ {subtitle}
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+
+ if (tooltip) {
+ return (
+
+ {body}
+
+ );
+ }
+ return body;
+};
diff --git a/dappnode/status/import-keys-warning-modal.tsx b/dappnode/status/import-keys-warning-modal.tsx
new file mode 100644
index 00000000..0fa127cc
--- /dev/null
+++ b/dappnode/status/import-keys-warning-modal.tsx
@@ -0,0 +1,44 @@
+import { Checkbox, Link } from '@lidofinance/lido-ui';
+import Modal, { LinkWrapper } from 'dappnode/components/modal';
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
+import { useState } from 'react';
+
+interface ImportKeysWarningModalProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+}
+export default function ImportKeysWarningModal({
+ isOpen,
+ setIsOpen,
+}: ImportKeysWarningModalProps) {
+ const { brainUrl } = useDappnodeUrls();
+ const [checked, setChecked] = useState(false);
+ const handleClose = () => {
+ setIsOpen(false);
+ };
+ return (
+
+ Key Import Advisory
+
+ It is crucial that the keys you are about to use are not active or
+ running on any other machine. Running the same keys in multiple
+ locations can lead to conflicts, loss of funds, or security
+ vulnerabilities.
+
+ Please confirm your understanding by checking the box below:
+ setChecked(e.target.checked)}
+ label="I understand it and promise I don't have these keys running somewhere else"
+ checked={checked}
+ />
+
+ {checked && (
+
+
+ Import keys
+
+
+ )}
+
+ );
+}
diff --git a/dappnode/status/status-section.tsx b/dappnode/status/status-section.tsx
new file mode 100644
index 00000000..f29d94fe
--- /dev/null
+++ b/dappnode/status/status-section.tsx
@@ -0,0 +1,80 @@
+import { FC } from 'react';
+import { SectionBlock, Stack } from 'shared/components';
+import { InfraItem, InfraItemProps } from './InfraItem';
+import { Card, Row } from './styles';
+import { Warnings } from './warnings';
+import { useGetInfraStatus } from 'dappnode/hooks/use-get-infra-status';
+import useApiBrain from 'dappnode/hooks/use-brain-keystore-api';
+import { capitalizeFirstChar } from 'dappnode/utils/capitalize-first-char';
+import { Link } from '@lidofinance/lido-ui';
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
+import useGetRelaysData from 'dappnode/hooks/use-get-relays-data';
+
+export const StatusSection: FC = () => {
+ const { ECName, ECStatus, CCName, CCStatus, isCCLoading, isECLoading } =
+ useGetInfraStatus();
+
+ const { isMEVRunning, isLoading: relaysLoading } = useGetRelaysData();
+ const {
+ pubkeys: brainKeys,
+ isLoading: brainLoading,
+ error: brainError,
+ } = useApiBrain();
+
+ const { stakersUiUrl } = useDappnodeUrls();
+
+ const infraItems: InfraItemProps[] = [
+ {
+ title: ECName ? capitalizeFirstChar(ECName) : '-',
+ subtitle: 'Execution Client',
+ status: ECStatus || 'NOT_INSTALLED',
+ isLoading: isECLoading,
+ },
+ {
+ title: CCName ? capitalizeFirstChar(CCName) : '-',
+ subtitle: 'Consensus Client',
+ status: CCStatus || 'NOT_INSTALLED',
+ isLoading: isCCLoading,
+ },
+ {
+ title: 'Web3signer',
+ subtitle: 'Signer',
+ status: brainKeys ? 'INSTALLED' : 'NOT_INSTALLED',
+ isLoading: brainLoading,
+ },
+ {
+ title: 'MEV Boost',
+ subtitle: 'Relays',
+ status: isMEVRunning ? 'INSTALLED' : 'NOT_INSTALLED',
+ isLoading: relaysLoading,
+ },
+ ];
+
+ return (
+
+
+
+
+ {infraItems.map((infra, i) => (
+
+ ))}
+
+
+ {!!brainError ||
+ ECStatus === 'NOT_INSTALLED' ||
+ CCStatus === 'NOT_INSTALLED' ||
+ !isMEVRunning ? (
+ Set up your node
+ ) : null}
+
+
+
+
+ );
+};
diff --git a/dappnode/status/styles.tsx b/dappnode/status/styles.tsx
new file mode 100644
index 00000000..f5726d45
--- /dev/null
+++ b/dappnode/status/styles.tsx
@@ -0,0 +1,89 @@
+import { StackStyle } from 'shared/components/stack/style';
+import styled from 'styled-components';
+
+export const Card = styled(StackStyle).attrs({ $gap: 'sm' })`
+ border-radius: ${({ theme }) => theme.borderRadiusesMap.md}px;
+ padding: 12px 16px;
+ background: var(--lido-color-backgroundSecondary);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+export const Row = styled(StackStyle).attrs({ $gap: 'sm' })`
+ width: 100%;
+ ${({ theme }) => theme.mediaQueries.lg} {
+ flex-direction: column;
+ gap: 12px;
+ }
+`;
+
+export const ItemStyled = styled(StackStyle).attrs({
+ $direction: 'column',
+ $gap: 'xs',
+})<{ $warning?: boolean }>`
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ flex: 1 0 20%;
+ row-gap: 8px;
+ font-size: 14px;
+
+ color: var(
+ ${({ $warning }) => ($warning ? '--lido-color-error' : '--lido-color-text')}
+ );
+ text-align: center;
+ align-items: center;
+
+ ${({ theme }) => theme.mediaQueries.lg} {
+ flex-direction: row;
+ justify-content: space-between;
+ }
+`;
+
+export const TitleStyled = styled.b`
+ font-size: 14px;
+ font-weight: 700;
+`;
+export const SubtitleStyled = styled.p`
+ font-size: 12px;
+`;
+
+export const WarningCard = styled(StackStyle).attrs<{ $hasWarning?: boolean }>(
+ (props) => ({
+ $hasWarning: props.$hasWarning ?? true,
+ }),
+)<{ $hasWarning?: boolean }>`
+ justify-content: center;
+ text-align: center;
+ border-radius: ${({ theme }) => theme.borderRadiusesMap.md}px;
+ padding: 12px 16px;
+ background: ${({ $hasWarning }) =>
+ $hasWarning
+ ? 'color-mix(in srgb, var(--lido-color-error) 15%, transparent)'
+ : 'var(--lido-color-backgroundSecondary)'};
+
+ button {
+ border: none;
+ background: none;
+ }
+`;
+
+export const NumWarningsLabel = styled.span`
+ font-weight: 600;
+ color: red;
+`;
+
+export const ValidatorMapStack = styled(StackStyle)`
+ ${({ theme }) => theme.mediaQueries.lg} {
+ width: 100%;
+ }
+`;
+
+export const AddressRow = styled(StackStyle).attrs({ $gap: 'xs' })`
+ align-items: center;
+ justify-content: center;
+`;
+
+export const Center = styled(StackStyle).attrs({ $gap: 'sm' })`
+ justify-content: center;
+ text-align: center;
+`;
diff --git a/dappnode/status/types.ts b/dappnode/status/types.ts
new file mode 100644
index 00000000..de5f2467
--- /dev/null
+++ b/dappnode/status/types.ts
@@ -0,0 +1,18 @@
+export const INFRA_STATUS = {
+ SYNCED: 'SYNCED',
+ SYNCING: 'SYNCING',
+ NOT_ALLOWED: 'NOT_ALLOWED',
+ NOT_INSTALLED: 'NOT_INSTALLED',
+ INSTALLED: 'INSTALLED',
+} as const;
+
+export type INFRA_STATUS = keyof typeof INFRA_STATUS;
+
+export type AllowedRelay = {
+ Description: string;
+ IsMandatory: boolean;
+ Operator: string;
+ Uri: string;
+};
+
+export type WarnedValidator = { index: string; pubkey: string };
diff --git a/dappnode/status/warnings.tsx b/dappnode/status/warnings.tsx
new file mode 100644
index 00000000..0530521e
--- /dev/null
+++ b/dappnode/status/warnings.tsx
@@ -0,0 +1,250 @@
+import { FC, useEffect, useState } from 'react';
+import { BeaconchainPubkeyLink, Stack } from 'shared/components';
+import {
+ AddressRow,
+ NumWarningsLabel,
+ ValidatorMapStack,
+ WarningCard,
+} from './styles';
+import { LoaderWrapperStyle } from 'shared/navigate/splash/loader-banner/styles';
+import { Link, Loader, Tooltip } from '@lidofinance/lido-ui';
+import { Address } from '@lidofinance/address';
+import { WarnedValidator } from './types';
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
+import useMissingKeys from 'dappnode/hooks/use-missing-keys';
+import useGetExitRequests from 'dappnode/hooks/use-get-exit-requests';
+import ImportKeysWarningModal from './import-keys-warning-modal';
+import useGetRelaysData from 'dappnode/hooks/use-get-relays-data';
+import { useGetInfraStatus } from 'dappnode/hooks/use-get-infra-status';
+
+export const Warnings: FC = () => {
+ const { brainUrl, stakersUiUrl, MEVPackageConfig } = useDappnodeUrls();
+ const { missingKeys, keysLoading, error: errorBrain } = useMissingKeys();
+ const {
+ exitRequests,
+ getExitRequests,
+ isLoading: exitsLoading,
+ } = useGetExitRequests();
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
+ const { ECStatus, CCStatus, isCCLoading, isECLoading } = useGetInfraStatus();
+ const {
+ isMEVRunning,
+ hasMandatoryRelay,
+ mandatoryRelays,
+ usedBlacklistedRelays,
+ isLoading: relaysLoading,
+ } = useGetRelaysData();
+
+ const [validatorsExitRequests, setValidatorsExitRequests] = useState<
+ WarnedValidator[]
+ >([]);
+
+ const [numWarnings, setNumWarnings] = useState(0);
+ useEffect(() => {
+ void getExitRequests();
+ }, [getExitRequests]);
+
+ useEffect(() => {
+ if (exitRequests) {
+ Object.keys(exitRequests).forEach((key) => {
+ setValidatorsExitRequests((prevState) => [
+ ...prevState,
+ {
+ index: exitRequests[key].event.ValidatorIndex,
+ pubkey: exitRequests[key].validator_pubkey_hex,
+ },
+ ]);
+ });
+ }
+ }, [exitRequests]);
+
+ useEffect(() => {
+ setNumWarnings(
+ validatorsExitRequests.length +
+ (errorBrain ? 1 : missingKeys.length) +
+ (ECStatus === 'NOT_INSTALLED' ? 1 : 0) +
+ (CCStatus === 'NOT_INSTALLED' ? 1 : 0) +
+ (isMEVRunning ? 0 : 1) +
+ (isMEVRunning && mandatoryRelays && !hasMandatoryRelay ? 1 : 0) +
+ (isMEVRunning && usedBlacklistedRelays.length > 0 ? 1 : 0),
+ );
+ }, [
+ validatorsExitRequests,
+ errorBrain,
+ missingKeys,
+ ECStatus,
+ CCStatus,
+ isMEVRunning,
+ mandatoryRelays,
+ hasMandatoryRelay,
+ usedBlacklistedRelays,
+ ]);
+
+ const WarningWrapper: FC<{
+ isLoading?: boolean;
+ showIf: boolean;
+ children: React.ReactNode;
+ }> = ({ isLoading = false, showIf, children }) => {
+ return isLoading ? (
+
+
+
+ ) : (
+ showIf && <>{children}>
+ );
+ };
+
+ return (
+
+
+ 0}>
+
+ {numWarnings > 0 ? (
+
+ You have {numWarnings}
+ warning/s
+
+ ) : (
+ "You don't have any warnings"
+ )}
+
+
+
+
+
+
+ Your Execution Client is not installed!
+ Please, select and sync a client from the Stakers tab.
+ Set Execution Client
+
+
+
+
+
+ Your Consensus Client is not installed!
+ Please, select and sync a client from the Stakers tab.
+ Set Consensus Client
+
+
+
+
+ 0 && !errorBrain}>
+
+
+ {missingKeys.length} keys are
+ not imported in Web3Signer
+
+ {missingKeys.map((key) => (
+
+
+
+
+ ))}
+ setIsImportModalOpen(true)}>
+ Import keys
+
+
+
+
+
+
+
+
+
+ Your Brain API is not Up!
+ Please, if Web3Signer is already installed, re-install it
+ Set Web3Signer
+
+
+
+ 0}>
+
+
+
+
+
+ {validatorsExitRequests.length}
+
+ Validator/s requested to exit
+
+
+ {validatorsExitRequests.map((val) => (
+
+ {val.index}
+
+
+ ))}
+ Exit validators
+
+
+
+
+
+
+
+
+ MEV Boost Package is not running
+ Install or restart your MEV Boost Package
+ Set MEV Boost
+
+
+
+
+
+
+ No mandatory Relays found
+
+ Select at least one of the mandatory MEV relays requested by
+ Lido
+
+ {mandatoryRelays?.map((relay, i) => (
+ {'- ' + relay.Operator}
+ ))}
+ Set up Relays
+
+
+
+
+ 0)}
+ >
+
+
+ Blacklisted Relays found
+ The following selected relays are blacklisted by Lido:
+ {usedBlacklistedRelays.map((relay, i) => (
+
+ ))}
+ Please, remove the Relays above from your MEV config
+ Remove blacklisted relays
+
+
+
+
+
+ );
+};
diff --git a/dappnode/utils/capitalize-first-char.ts b/dappnode/utils/capitalize-first-char.ts
new file mode 100644
index 00000000..e7e7bae0
--- /dev/null
+++ b/dappnode/utils/capitalize-first-char.ts
@@ -0,0 +1,3 @@
+export const capitalizeFirstChar = (str: string): string => {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+};
diff --git a/dappnode/utils/dappnode-docs-urls.ts b/dappnode/utils/dappnode-docs-urls.ts
new file mode 100644
index 00000000..5a448e4f
--- /dev/null
+++ b/dappnode/utils/dappnode-docs-urls.ts
@@ -0,0 +1,12 @@
+const baseUrl =
+ 'https://docs.dappnode.io/docs/user/staking/ethereum/lsd-pools/lido/';
+
+export const dappnodeLidoDocsUrls = {
+ register:
+ baseUrl + 'register/#first-steps-to-create-a-node-operator-in-dappnode',
+ generateKeys: baseUrl + 'register/#2-create-the-keystores--deposit-data',
+ notificationsOperatorExists:
+ baseUrl + 'already-node-operator#3-configuring-telegram-notifications',
+ notificationsNewOperator: baseUrl + 'register/#5-setup-notifications',
+ pendingHashes: baseUrl + 'performance',
+};
diff --git a/dappnode/utils/fetchWithRetry.ts b/dappnode/utils/fetchWithRetry.ts
new file mode 100644
index 00000000..74b37784
--- /dev/null
+++ b/dappnode/utils/fetchWithRetry.ts
@@ -0,0 +1,20 @@
+export const fetchWithRetry = async (
+ url: string,
+ options: RequestInit,
+ timeout: number,
+): Promise => {
+ const shouldRetry = true;
+ while (shouldRetry) {
+ const response = await fetch(url, options);
+ if (response.status === 202) {
+ console.debug(
+ `Received status 202. Retrying in ${timeout / 1000} seconds...`,
+ );
+ await new Promise((resolve) => setTimeout(resolve, timeout));
+ } else {
+ return response;
+ }
+ }
+
+ return new Response();
+};
diff --git a/dappnode/utils/is-tg-bot-token.ts b/dappnode/utils/is-tg-bot-token.ts
new file mode 100644
index 00000000..b19edff6
--- /dev/null
+++ b/dappnode/utils/is-tg-bot-token.ts
@@ -0,0 +1,4 @@
+export default function isTelegramBotToken(token: string): boolean {
+ const telegramBotTokenPattern = /^\d{7,10}:[a-zA-Z0-9_-]{35}$/;
+ return telegramBotTokenPattern.test(token);
+}
diff --git a/dappnode/utils/is-tg-user-id.ts b/dappnode/utils/is-tg-user-id.ts
new file mode 100644
index 00000000..a6cc0e81
--- /dev/null
+++ b/dappnode/utils/is-tg-user-id.ts
@@ -0,0 +1,4 @@
+export default function isTelegramUserID(id: string): boolean {
+ const telegramUserIDPattern = /^\d+$/;
+ return telegramUserIDPattern.test(id);
+}
diff --git a/dappnode/utils/sanitize-urls.ts b/dappnode/utils/sanitize-urls.ts
new file mode 100644
index 00000000..12cdaf98
--- /dev/null
+++ b/dappnode/utils/sanitize-urls.ts
@@ -0,0 +1,26 @@
+export const sanitizeUrl = (url: string): string => {
+ if (!url) return '';
+
+ // Trim spaces
+ let sanitizedUrl = url.trim();
+
+ // Remove trailing slash unless it's just a root "/"
+ sanitizedUrl =
+ sanitizedUrl.endsWith('/') && sanitizedUrl !== '/'
+ ? sanitizedUrl.slice(0, -1)
+ : sanitizedUrl;
+
+ // Remove duplicate slashes (except in protocol)
+ sanitizedUrl = sanitizedUrl.replace(/([^:]\/)\/+/g, '$1');
+
+ // Encode special characters in the URL
+ try {
+ const urlObject = new URL(sanitizedUrl);
+ sanitizedUrl = urlObject.toString();
+ } catch (error) {
+ console.error('Invalid URL:', sanitizedUrl);
+ return '';
+ }
+
+ return sanitizedUrl;
+};
diff --git a/faq/notifications-1.md b/faq/notifications-1.md
new file mode 100644
index 00000000..0b8df581
--- /dev/null
+++ b/faq/notifications-1.md
@@ -0,0 +1,9 @@
+---
+title: Why are notifications crucial?
+---
+
+Notifications are essential for staying informed about critical events within the Lido CSM protocol. By receiving alerts about exit requests, deposits, penalties, slashing incidents, and smart contract events, you can proactively manage your staking operations and address issues promptly.
+
+Staying informed helps reduce risks while maintaining transparency and control over your activities, ensuring smooth and efficient participation in the protocol.
+
+Learn more about this notifications in [our documentation](https://docs.dappnode.io/docs/user/staking/ethereum/lsd-pools/lido/notifications).
diff --git a/faq/notifications-2.md b/faq/notifications-2.md
new file mode 100644
index 00000000..d307dcbb
--- /dev/null
+++ b/faq/notifications-2.md
@@ -0,0 +1,7 @@
+---
+title: How to Get Your Telegram User ID
+---
+
+1. Open [Telegram](https://web.telegram.org/a/) and search for [`@userinfobot`](https://web.telegram.org/a/#52504489) or [`@raw_data_bot`](https://web.telegram.org/a/#1533228735).
+2. Start a chat with the bot by clicking Start.
+3. The bot will reply with your Telegram ID.
diff --git a/faq/notifications-3.md b/faq/notifications-3.md
new file mode 100644
index 00000000..db5fa1da
--- /dev/null
+++ b/faq/notifications-3.md
@@ -0,0 +1,10 @@
+---
+title: How to Create a Telegram Bot and Get the Bot Token
+---
+
+1. Open Telegram and search for [`@BotFather`](https://web.telegram.org/a/#93372553).
+2. Start a chat with BotFather and type `/newbot`.
+3. Follow the instructions to name your bot and choose a username (must end with "bot").
+4. Once created, BotFather will send you the bot token.
+ - Example: `123456789:ABCDefghIJKLMNOPQRSTuvwxYZ`.
+5. Open the chat with your bot and clib the "`Start`" button.
diff --git a/faq/performance-1.md b/faq/performance-1.md
new file mode 100644
index 00000000..5b60d56e
--- /dev/null
+++ b/faq/performance-1.md
@@ -0,0 +1,7 @@
+---
+title: What is the Lido threshold?
+---
+
+The Lido threshold is the value that determines whether a validator should receive rewards or not. It is calculated with the average of all the efficiencies (attestation rates) of all validators.
+
+Validators with an efficiency higher than the threshold will get rewards, while those below it won’t.
diff --git a/faq/performance-2.md b/faq/performance-2.md
new file mode 100644
index 00000000..794b1e49
--- /dev/null
+++ b/faq/performance-2.md
@@ -0,0 +1,5 @@
+---
+title: Where does the data come from?
+---
+
+We obtain the performance data of all Lido operators through its Smart Contract. The Lido CSM team distributes reports from all validators via IPFS hashes and Lido CSM package proccess it. Since this data is provided by Lido, is crucial in determining whether your validators qualify for rewards.
diff --git a/faq/performance-3.md b/faq/performance-3.md
new file mode 100644
index 00000000..1b5c7d79
--- /dev/null
+++ b/faq/performance-3.md
@@ -0,0 +1,5 @@
+---
+title: How often is the data updated?
+---
+
+The Lido CSM team distributes a new report every 28 days, so it can take up to almost a monthly delay when checking your current performance compared to other Lido operators.
diff --git a/faq/performance-4.md b/faq/performance-4.md
new file mode 100644
index 00000000..e3bcc0a6
--- /dev/null
+++ b/faq/performance-4.md
@@ -0,0 +1,5 @@
+---
+title: Where is this data stored?
+---
+
+The data collected from the Lido Smart Contract is stored in the Dappnode Lido package. Note that this data will include all samples from the validator; so the historical data from before the installation will be available.
diff --git a/faq/performance-5.md b/faq/performance-5.md
new file mode 100644
index 00000000..98eb5ad8
--- /dev/null
+++ b/faq/performance-5.md
@@ -0,0 +1,5 @@
+---
+title: Is the data accurate?
+---
+
+To calculate the efficiency, which is used to compare with the Lido threshold, we rely on data from the Lido Smart Contract. This source is considered 100% accurate since its the data that will be used by Lido when allocating the rewards.
diff --git a/faq/testnet-notifications-1.md b/faq/testnet-notifications-1.md
new file mode 100644
index 00000000..0b8df581
--- /dev/null
+++ b/faq/testnet-notifications-1.md
@@ -0,0 +1,9 @@
+---
+title: Why are notifications crucial?
+---
+
+Notifications are essential for staying informed about critical events within the Lido CSM protocol. By receiving alerts about exit requests, deposits, penalties, slashing incidents, and smart contract events, you can proactively manage your staking operations and address issues promptly.
+
+Staying informed helps reduce risks while maintaining transparency and control over your activities, ensuring smooth and efficient participation in the protocol.
+
+Learn more about this notifications in [our documentation](https://docs.dappnode.io/docs/user/staking/ethereum/lsd-pools/lido/notifications).
diff --git a/faq/testnet-notifications-2.md b/faq/testnet-notifications-2.md
new file mode 100644
index 00000000..d307dcbb
--- /dev/null
+++ b/faq/testnet-notifications-2.md
@@ -0,0 +1,7 @@
+---
+title: How to Get Your Telegram User ID
+---
+
+1. Open [Telegram](https://web.telegram.org/a/) and search for [`@userinfobot`](https://web.telegram.org/a/#52504489) or [`@raw_data_bot`](https://web.telegram.org/a/#1533228735).
+2. Start a chat with the bot by clicking Start.
+3. The bot will reply with your Telegram ID.
diff --git a/faq/testnet-notifications-3.md b/faq/testnet-notifications-3.md
new file mode 100644
index 00000000..db5fa1da
--- /dev/null
+++ b/faq/testnet-notifications-3.md
@@ -0,0 +1,10 @@
+---
+title: How to Create a Telegram Bot and Get the Bot Token
+---
+
+1. Open Telegram and search for [`@BotFather`](https://web.telegram.org/a/#93372553).
+2. Start a chat with BotFather and type `/newbot`.
+3. Follow the instructions to name your bot and choose a username (must end with "bot").
+4. Once created, BotFather will send you the bot token.
+ - Example: `123456789:ABCDefghIJKLMNOPQRSTuvwxYZ`.
+5. Open the chat with your bot and clib the "`Start`" button.
diff --git a/faq/testnet-performance-1.md b/faq/testnet-performance-1.md
new file mode 100644
index 00000000..5b60d56e
--- /dev/null
+++ b/faq/testnet-performance-1.md
@@ -0,0 +1,7 @@
+---
+title: What is the Lido threshold?
+---
+
+The Lido threshold is the value that determines whether a validator should receive rewards or not. It is calculated with the average of all the efficiencies (attestation rates) of all validators.
+
+Validators with an efficiency higher than the threshold will get rewards, while those below it won’t.
diff --git a/faq/testnet-performance-2.md b/faq/testnet-performance-2.md
new file mode 100644
index 00000000..794b1e49
--- /dev/null
+++ b/faq/testnet-performance-2.md
@@ -0,0 +1,5 @@
+---
+title: Where does the data come from?
+---
+
+We obtain the performance data of all Lido operators through its Smart Contract. The Lido CSM team distributes reports from all validators via IPFS hashes and Lido CSM package proccess it. Since this data is provided by Lido, is crucial in determining whether your validators qualify for rewards.
diff --git a/faq/testnet-performance-3.md b/faq/testnet-performance-3.md
new file mode 100644
index 00000000..75d4a96a
--- /dev/null
+++ b/faq/testnet-performance-3.md
@@ -0,0 +1,5 @@
+---
+title: How often is the data updated?
+---
+
+The Lido CSM team distributes a new report every 7 days, so it can take up to a weekly delay when checking your current performance compared to other Lido operators.
diff --git a/faq/testnet-performance-4.md b/faq/testnet-performance-4.md
new file mode 100644
index 00000000..e3bcc0a6
--- /dev/null
+++ b/faq/testnet-performance-4.md
@@ -0,0 +1,5 @@
+---
+title: Where is this data stored?
+---
+
+The data collected from the Lido Smart Contract is stored in the Dappnode Lido package. Note that this data will include all samples from the validator; so the historical data from before the installation will be available.
diff --git a/faq/testnet-performance-5.md b/faq/testnet-performance-5.md
new file mode 100644
index 00000000..98eb5ad8
--- /dev/null
+++ b/faq/testnet-performance-5.md
@@ -0,0 +1,5 @@
+---
+title: Is the data accurate?
+---
+
+To calculate the efficiency, which is used to compare with the Lido threshold, we rely on data from the Lido Smart Contract. This source is considered 100% accurate since its the data that will be used by Lido when allocating the rewards.
diff --git a/features/add-keys/add-keys/context/add-keys-form-provider.tsx b/features/add-keys/add-keys/context/add-keys-form-provider.tsx
index 33b13e32..d700d564 100644
--- a/features/add-keys/add-keys/context/add-keys-form-provider.tsx
+++ b/features/add-keys/add-keys/context/add-keys-form-provider.tsx
@@ -1,4 +1,4 @@
-import { FC, PropsWithChildren, useMemo } from 'react';
+import { FC, PropsWithChildren, useCallback, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import {
FormControllerContext,
@@ -14,6 +14,8 @@ import { useAddKeysSubmit } from './use-add-keys-submit';
import { useAddKeysValidation } from './use-add-keys-validation';
import { useFormBondAmount } from './use-form-bond-amount';
import { useGetDefaultValues } from './use-get-default-values';
+// DAPPNODE
+import useBrainLaunchpadApi from 'dappnode/hooks/use-brain-launchpad-api';
export const useAddKeysFormData = useFormData;
@@ -23,6 +25,9 @@ export const AddKeysFormProvider: FC = ({ children }) => {
const asyncDefaultValues = useGetDefaultValues(networkData);
+ // DAPPNODE
+ const { submitKeystores: submitKeysToBrain } = useBrainLaunchpadApi();
+
const formObject = useForm({
defaultValues: asyncDefaultValues,
resolver: validationResolver,
@@ -39,15 +44,29 @@ export const AddKeysFormProvider: FC = ({ children }) => {
onRetry: retryFire,
});
+ // DAPPNODE
+ const handleSubmit = useCallback(
+ async (
+ input: AddKeysFormInputType,
+ networkData: AddKeysFormNetworkData,
+ ) => {
+ const { keystores, password } = formObject.getValues();
+ if (keystores && password)
+ await submitKeysToBrain({ keystores, password });
+ return await addKeys(input, networkData);
+ },
+ [addKeys, formObject, submitKeysToBrain], // dependencies
+ );
+
const formControllerValue: FormControllerContextValueType<
AddKeysFormInputType,
AddKeysFormNetworkData
> = useMemo(
() => ({
- onSubmit: addKeys,
+ onSubmit: handleSubmit,
retryEvent,
}),
- [addKeys, retryEvent],
+ [handleSubmit, retryEvent],
);
return (
diff --git a/features/add-keys/add-keys/context/types.ts b/features/add-keys/add-keys/context/types.ts
index d0feb472..c1cf006c 100644
--- a/features/add-keys/add-keys/context/types.ts
+++ b/features/add-keys/add-keys/context/types.ts
@@ -4,9 +4,17 @@ import { DepositDataInputType } from 'shared/hook-form/form-controller';
import { KeysAvailable, ShareLimitInfo } from 'shared/hooks';
import { BondBalance, LoadingRecord, NodeOperatorId } from 'types';
+// DAPPNODE
+export interface KeysFile {
+ name: string;
+ content: { pubkey: string };
+}
+
export type AddKeysFormInputType = {
token: TOKENS;
bondAmount?: BigNumber;
+ keystores?: KeysFile[]; //dappnode
+ password?: string; //dappnode
} & DepositDataInputType;
export type AddKeysFormNetworkData = {
diff --git a/features/add-keys/add-keys/controls/keys-input.tsx b/features/add-keys/add-keys/controls/keys-input.tsx
index 91332778..a5e18a98 100644
--- a/features/add-keys/add-keys/controls/keys-input.tsx
+++ b/features/add-keys/add-keys/controls/keys-input.tsx
@@ -4,6 +4,10 @@ import { useFormState } from 'react-hook-form';
import { FormTitle, MatomoLink } from 'shared/components';
import { DepositDataInputHookForm } from 'shared/hook-form/controls';
import { AddKeysFormInputType } from '../context';
+// DAPPNODE
+import useCheckImportedDepositKeys from 'dappnode/hooks/use-check-deposit-keys';
+import { KeysBrainUpload } from 'dappnode/import-keys/keys-input-form';
+import { useFormContext } from 'react-hook-form';
export const KeysInput = () => {
const { errors } = useFormState({
@@ -11,6 +15,11 @@ export const KeysInput = () => {
});
const error = errors.rawDepositData?.message || errors.depositData?.message;
+ // DAPPNODE
+ const { watch } = useFormContext();
+ const depositDataValue = watch('depositData');
+ const { missingKeys } = useCheckImportedDepositKeys(depositDataValue);
+
return (
<>
{
Upload deposit data
+ {missingKeys.length > 0 && (
+
+ )}
>
);
};
diff --git a/features/create-node-operator/submit-keys-form/context/submit-keys-form-provider.tsx b/features/create-node-operator/submit-keys-form/context/submit-keys-form-provider.tsx
index e766ab02..db0827f8 100644
--- a/features/create-node-operator/submit-keys-form/context/submit-keys-form-provider.tsx
+++ b/features/create-node-operator/submit-keys-form/context/submit-keys-form-provider.tsx
@@ -1,5 +1,5 @@
import { useModifyContext } from 'providers/modify-provider';
-import { FC, PropsWithChildren, useMemo } from 'react';
+import { FC, PropsWithChildren, useCallback, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import {
FormControllerContext,
@@ -18,6 +18,8 @@ import { useGetDefaultValues } from './use-get-default-values';
import { useSubmitKeysFormNetworkData } from './use-submit-keys-form-network-data';
import { useSubmitKeysSubmit } from './use-submit-keys-submit';
import { useSubmitKeysValidation } from './use-submit-keys-validation';
+// DAPPNODE
+import useBrainLaunchpadApi from 'dappnode/hooks/use-brain-launchpad-api';
export const useSubmitKeysFormData = useFormData;
@@ -39,21 +41,37 @@ export const SubmitKeysFormProvider: FC = ({ children }) => {
useFormDepositData(formObject);
const { retryEvent, retryFire } = useFormControllerRetry();
+ // DAPPNODE
+ const { submitKeystores: submitKeysToBrain } = useBrainLaunchpadApi();
const submitKeys = useSubmitKeysSubmit({
onConfirm: revalidate,
onRetry: retryFire,
});
+ // DAPPNODE
+ const handleSubmit = useCallback(
+ async (
+ input: SubmitKeysFormInputType,
+ networkData: SubmitKeysFormNetworkData,
+ ) => {
+ const { keystores, password } = formObject.getValues();
+ if (keystores && password)
+ await submitKeysToBrain({ keystores, password });
+ return await submitKeys(input, networkData);
+ },
+ [formObject, submitKeys, submitKeysToBrain], // dependencies
+ );
+
const formControllerValue: FormControllerContextValueType<
SubmitKeysFormInputType,
SubmitKeysFormNetworkData
> = useMemo(
() => ({
- onSubmit: submitKeys,
+ onSubmit: handleSubmit,
retryEvent,
}),
- [submitKeys, retryEvent],
+ [handleSubmit, retryEvent],
);
return (
diff --git a/features/create-node-operator/submit-keys-form/context/types.ts b/features/create-node-operator/submit-keys-form/context/types.ts
index 6391b1ae..8e081b30 100644
--- a/features/create-node-operator/submit-keys-form/context/types.ts
+++ b/features/create-node-operator/submit-keys-form/context/types.ts
@@ -1,5 +1,6 @@
import { type TOKENS } from 'consts/tokens';
import { BigNumber } from 'ethers';
+import { KeysFile } from 'features/add-keys/add-keys/context/types';
import { DepositDataInputType } from 'shared/hook-form/form-controller';
import { KeysAvailable, ShareLimitInfo } from 'shared/hooks';
import { LoadingRecord, Proof } from 'types';
@@ -14,6 +15,8 @@ export type SubmitKeysFormInputType = {
extendedManagerPermissions: boolean;
specifyCustomAddresses: boolean;
specifyReferrrer: boolean;
+ keystores?: KeysFile[]; // DAPPNODE
+ password?: string; // DAPPNODE
} & DepositDataInputType;
export type SubmitKeysFormNetworkData = {
diff --git a/features/create-node-operator/submit-keys-form/controls/keys-input.tsx b/features/create-node-operator/submit-keys-form/controls/keys-input.tsx
index 95a53ef2..b3233016 100644
--- a/features/create-node-operator/submit-keys-form/controls/keys-input.tsx
+++ b/features/create-node-operator/submit-keys-form/controls/keys-input.tsx
@@ -4,6 +4,10 @@ import { useFormState } from 'react-hook-form';
import { FormTitle, MatomoLink } from 'shared/components';
import { DepositDataInputHookForm } from 'shared/hook-form/controls';
import { SubmitKeysFormInputType } from '../context';
+// DAPPNODE
+import useCheckImportedDepositKeys from 'dappnode/hooks/use-check-deposit-keys';
+import { KeysBrainUpload } from 'dappnode/import-keys/keys-input-form';
+import { useFormContext } from 'react-hook-form';
export const KeysInput = () => {
const { errors } = useFormState({
@@ -11,6 +15,11 @@ export const KeysInput = () => {
});
const error = errors.rawDepositData?.message || errors.depositData?.message;
+ // DAPPNODE
+ const { watch } = useFormContext();
+ const depositDataValue = watch('depositData');
+ const { missingKeys } = useCheckImportedDepositKeys(depositDataValue);
+
return (
<>
{
Upload deposit data
+ {missingKeys.length > 0 && (
+
+ )}
>
);
};
diff --git a/features/dashboard/dashboard.tsx b/features/dashboard/dashboard.tsx
index 411a5d94..dba85145 100644
--- a/features/dashboard/dashboard.tsx
+++ b/features/dashboard/dashboard.tsx
@@ -3,10 +3,16 @@ import { BondSection } from './bond';
import { KeysSection } from './keys';
import { RolesSection } from './roles';
import { ExternalSection } from './external';
+import { StatusSection } from 'dappnode/status/status-section';
+import { NotificationsModal } from 'dappnode/notifications/notifications-modal';
export const Dashboard: FC = () => {
return (
<>
+ {/* DAPPNODE */}
+
+
+
diff --git a/features/stealing/locked-section/locked-section.tsx b/features/stealing/locked-section/locked-section.tsx
index e73f1f1a..39c7c5e5 100644
--- a/features/stealing/locked-section/locked-section.tsx
+++ b/features/stealing/locked-section/locked-section.tsx
@@ -1,8 +1,10 @@
import { Block } from '@lidofinance/lido-ui';
import { FC } from 'react';
import { WhenLoaded } from 'shared/components';
-import { useNodeOperatorsWithLockedBond } from 'shared/hooks';
+// import { useNodeOperatorsWithLockedBond } from 'shared/hooks';
import { LockedTable } from './locked-table';
+// DAPPNODE
+import { useNodeOperatorsWithLockedBond } from 'dappnode/hooks/use-node-operators-with-locked-bond-api';
export const LockedSection: FC = () => {
const { data, initialLoading: loading } = useNodeOperatorsWithLockedBond();
diff --git a/features/welcome/try-csm/try-csm.tsx b/features/welcome/try-csm/try-csm.tsx
index a092b7b1..c5417868 100644
--- a/features/welcome/try-csm/try-csm.tsx
+++ b/features/welcome/try-csm/try-csm.tsx
@@ -6,11 +6,15 @@ import { FC } from 'react';
import { MatomoLink } from 'shared/components';
import { StyledBlock, StyledStack } from './styles';
import { MATOMO_CLICK_EVENTS_TYPES } from 'consts/matomo-click-events';
+// DAPPNODE
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
const { defaultChain } = getConfig();
export const TryCSM: FC = () => {
const isMainnet = defaultChain === CHAINS.Mainnet;
+ // DAPPNODE
+ const { installerTabUrl } = useDappnodeUrls();
if (isMainnet)
return (
@@ -19,6 +23,16 @@ export const TryCSM: FC = () => {
Try CSM on Holesky
+ {/* DAPPNODE */}
+
+
+ Join CSM Testnet
+
+
'keys-8',
'keys-9',
'keys-10',
- 'keys-13',
'keys-11',
- 'keys-12',
]);
export const getFaqBond = () =>
@@ -86,3 +84,15 @@ export const getFaqLocked = () =>
export const getFaqRoles = () =>
readFaqFiles(['roles-1', 'roles-2', 'roles-3', 'roles-4', 'roles-5']);
+
+export const getFaqNotifications = () =>
+ readFaqFiles(['notifications-1', 'notifications-2', 'notifications-3']);
+
+export const getFaqPerformance = () =>
+ readFaqFiles([
+ 'performance-1',
+ 'performance-2',
+ 'performance-3',
+ 'performance-4',
+ 'performance-5',
+ ]);
diff --git a/next.config.mjs b/next.config.mjs
index 1a5f7f32..5d957aff 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -115,6 +115,70 @@ export default withBundleAnalyzer({
return config;
},
+
+ // DAPPNODE Rewrites to proxy the request
+ async rewrites() {
+ return [
+ {
+ source: '/api/consensus-version-mainnet',
+ destination:
+ 'http://beacon-chain.mainnet.dncore.dappnode:3500/eth/v1/node/version',
+ },
+ {
+ source: '/api/consensus-status-mainnet',
+ destination:
+ 'http://beacon-chain.mainnet.dncore.dappnode:3500/eth/v1/node/syncing',
+ },
+ {
+ source: '/api/consensus-version-holesky',
+ destination:
+ 'http://beacon-chain.holesky.dncore.dappnode:3500/eth/v1/node/version',
+ },
+ {
+ source: '/api/consensus-status-holesky',
+ destination:
+ 'http://beacon-chain.holesky.dncore.dappnode:3500/eth/v1/node/syncing',
+ },
+ {
+ source: '/api/keys-status-mainnet',
+ destination:
+ 'http://beacon-chain.mainnet.dncore.dappnode:3500/eth/v1/beacon/states/head/validators',
+ },
+ {
+ source: '/api/keys-status-holesky',
+ destination:
+ 'http://beacon-chain.holesky.dncore.dappnode:3500/eth/v1/beacon/states/head/validators',
+ },
+ {
+ source: '/api/brain-keys-mainnet',
+ destination:
+ 'http://brain.web3signer.dappnode:5000/api/v0/brain/validators?tag=lido&format=pubkey',
+ },
+ {
+ source: '/api/brain-keys-holesky',
+ destination:
+ 'http://brain.web3signer-holesky.dappnode:5000/api/v0/brain/validators?tag=lido&format=pubkey',
+ },
+ {
+ source: '/api/brain-launchpad-mainnet',
+ destination: 'http://brain.web3signer.dappnode:3000/eth/v1/keystores',
+ },
+ {
+ source: '/api/brain-launchpad-holesky',
+ destination:
+ 'http://brain.web3signer-holesky.dappnode:3000/eth/v1/keystores',
+ },
+ {
+ source: '/api/mev-status-mainnet',
+ destination: 'http://mev-boost.dappnode:18550/',
+ },
+ {
+ source: '/api/mev-status-holesky',
+ destination: 'http://mev-boost-holesky.dappnode:18550/',
+ },
+ ];
+ },
+
async headers() {
return [
{
@@ -189,5 +253,12 @@ export default withBundleAnalyzer({
publicRuntimeConfig: {
basePath,
developmentMode,
+ // DAPPNODE
+ rpcUrls_1: process.env.EL_RPC_URLS_1,
+ rpcUrls_17000: process.env.EL_RPC_URLS_17000,
+ defaultChain: parseInt(process.env.DEFAULT_CHAIN),
+ supportedChains: process.env?.SUPPORTED_CHAINS?.split(',').map((chainId) =>
+ parseInt(chainId, 10),
+ ),
},
});
diff --git a/package.json b/package.json
index f921c861..5ca83dc8 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"react-hook-form": "^7.52.1",
"react-is": "^18.3.1",
"react-transition-group": "^4.4.2",
+ "recharts": "^2.15.1",
"reef-knot": "^4.2.0",
"remark": "^13.0.0",
"remark-external-links": "^8.0.0",
@@ -99,6 +100,7 @@
"@types/nprogress": "^0.2.0",
"@types/react": "^18.2.22",
"@types/react-transition-group": "^4.4.3",
+ "@types/recharts": "^1.8.29",
"@types/styled-components": "^5.1.23",
"@types/styled-system": "^5.1.17",
"@types/uuid": "^8.3.2",
diff --git a/pages/index.tsx b/pages/index.tsx
index 87b735a4..ec49dc8a 100644
--- a/pages/index.tsx
+++ b/pages/index.tsx
@@ -1,4 +1,7 @@
import { SecretConfigType } from 'config';
+import { ECNoLogsPage } from 'dappnode/fallbacks/ec-no-logs-page';
+import { ECNotInstalledPage } from 'dappnode/fallbacks/ec-not-installed-page';
+import { ECSyncingPage } from 'dappnode/fallbacks/ec-syncing-page';
import { DashboardPage } from 'features/dashboard';
import { StarterPackPage } from 'features/starter-pack';
import { WelcomePage } from 'features/welcome';
@@ -14,13 +17,20 @@ const Page: FC = ({ maintenance }) => {
if (maintenance) return ;
return (
-
- }>
- }>
-
+ // DAPPNODE GATES: IS_EXECUTION_INSTALLED, IS_EXECUTION_SYNCED, EXECUTION_HAS_LOGS
+ }>
+ }>
+ }>
+
+ }>
+ }>
+
+
+
+
-
+
);
};
diff --git a/providers/node-operator-provider/use-get-active-node-operator.ts b/providers/node-operator-provider/use-get-active-node-operator.ts
index afd5eab5..bd2237c3 100644
--- a/providers/node-operator-provider/use-get-active-node-operator.ts
+++ b/providers/node-operator-provider/use-get-active-node-operator.ts
@@ -1,10 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
import { NodeOperator, NodeOperatorId } from 'types';
import { useCachedId } from './use-cached-id';
+// DAPPNODE
+import useDappnodeUrls from 'dappnode/hooks/use-dappnode-urls';
export const useGetActiveNodeOperator = (list?: NodeOperator[]) => {
const [active, setActive] = useState();
const [cachedId, setCachedId] = useCachedId();
+ // DAPPNODE
+ const { backendUrl } = useDappnodeUrls();
useEffect(() => {
if (list) {
@@ -21,6 +25,36 @@ export const useGetActiveNodeOperator = (list?: NodeOperator[]) => {
active && setCachedId(active.id);
}, [active, setCachedId]);
+ useEffect(() => {
+ const postNodeOperatorId = async () => {
+ if (cachedId) {
+ try {
+ console.debug(`POSTing node operator id to events indexer API`);
+ const response = await fetch(
+ `${backendUrl}/api/v0/events_indexer/operatorId`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ operatorId: cachedId }),
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ } catch (e) {
+ console.error(
+ `Error POSTing node operator id to events indexer API: ${e}`,
+ );
+ }
+ }
+ };
+
+ void postNodeOperatorId();
+ }, [backendUrl, cachedId]);
+
const switchActive = useCallback(
(id: NodeOperatorId) => {
const fromList = list?.find((item) => item.id === id);
diff --git a/shared/components/status-chip/status-chip.tsx b/shared/components/status-chip/status-chip.tsx
index 92cf3169..ee77aa0c 100644
--- a/shared/components/status-chip/status-chip.tsx
+++ b/shared/components/status-chip/status-chip.tsx
@@ -2,11 +2,14 @@ import { FC } from 'react';
import { StatusStyle, Variants } from './style';
import { KEY_STATUS } from 'consts/key-status';
+// DAPPNODE
+import { INFRA_STATUS } from 'dappnode/status/types';
+
type Props = {
- status?: KEY_STATUS;
+ status?: KEY_STATUS | INFRA_STATUS;
};
-const variants: Record = {
+const variants: Record = {
[KEY_STATUS.NON_QUEUED]: 'warning',
[KEY_STATUS.DEPOSITABLE]: 'default',
[KEY_STATUS.ACTIVATION_PENDING]: 'default',
@@ -23,9 +26,16 @@ const variants: Record = {
[KEY_STATUS.EXIT_REQUESTED]: 'warning',
[KEY_STATUS.STUCK]: 'error',
[KEY_STATUS.SLASHED]: 'secondary',
+
+ //DAPPNODE
+ [INFRA_STATUS.SYNCED]: 'success',
+ [INFRA_STATUS.SYNCING]: 'warning',
+ [INFRA_STATUS.NOT_ALLOWED]: 'error',
+ [INFRA_STATUS.NOT_INSTALLED]: 'error',
+ [INFRA_STATUS.INSTALLED]: 'success',
};
-export const StatusTitle: Record = {
+export const StatusTitle: Record = {
[KEY_STATUS.NON_QUEUED]: 'Non queued',
[KEY_STATUS.DEPOSITABLE]: 'Depositable',
[KEY_STATUS.ACTIVATION_PENDING]: 'Activation pending',
@@ -42,6 +52,13 @@ export const StatusTitle: Record = {
[KEY_STATUS.EXIT_REQUESTED]: 'Exit requested',
[KEY_STATUS.STUCK]: 'Stuck',
[KEY_STATUS.SLASHED]: 'Slashed',
+
+ //DAPPNODE
+ [INFRA_STATUS.SYNCED]: 'Synced',
+ [INFRA_STATUS.SYNCING]: 'Syncing',
+ [INFRA_STATUS.NOT_ALLOWED]: 'Not allowed',
+ [INFRA_STATUS.NOT_INSTALLED]: 'Not installed',
+ [INFRA_STATUS.INSTALLED]: 'Installed',
};
export const StatusChip: FC = ({ status }) => (
diff --git a/shared/hooks/use-csm-node-operators.ts b/shared/hooks/use-csm-node-operators.ts
index 322944ab..31d35926 100644
--- a/shared/hooks/use-csm-node-operators.ts
+++ b/shared/hooks/use-csm-node-operators.ts
@@ -1,11 +1,14 @@
import { useLidoSWR } from '@lido-sdk/react';
import { STRATEGY_LAZY } from 'consts/swr-strategies';
-import { useNodeOperatorsFetcherFromEvents } from './use-node-operators-fetcher-from-events';
+// import { useNodeOperatorsFetcherFromEvents } from './use-node-operators-fetcher-from-events';
import { useAccount } from './use-account';
+// DAPPNODE
+import { useNodeOperatorsFetcherFromAPI } from 'dappnode/hooks/use-node-operators-fetcher-from-events-api';
export const useCsmNodeOperators = () => {
const { chainId, address } = useAccount();
- const fetcher = useNodeOperatorsFetcherFromEvents(address, chainId);
+ // const fetcher = useNodeOperatorsFetcherFromEvents(address, chainId);
+ const fetcher = useNodeOperatorsFetcherFromAPI(address);
return useLidoSWR(
['no-list', address, chainId],
diff --git a/shared/hooks/use-invites.ts b/shared/hooks/use-invites.ts
index 8dfa884d..57a36d12 100644
--- a/shared/hooks/use-invites.ts
+++ b/shared/hooks/use-invites.ts
@@ -1,7 +1,9 @@
import { useLidoSWR } from '@lido-sdk/react';
import { STRATEGY_LAZY } from 'consts/swr-strategies';
import { useAccount } from 'shared/hooks';
-import { useInvitesEventsFetcher } from './use-invites-events-fetcher';
+// import { useInvitesEventsFetcher } from './use-invites-events-fetcher';
+// DAPPNODE
+import { useInvitesEventsFetcher } from 'dappnode/hooks/use-invites-events-fetcher-api';
export const useInvites = (config = STRATEGY_LAZY) => {
const { chainId, address } = useAccount();
diff --git a/shared/hooks/use-keys-with-status.ts b/shared/hooks/use-keys-with-status.ts
index d84ebb35..bb9e8a39 100644
--- a/shared/hooks/use-keys-with-status.ts
+++ b/shared/hooks/use-keys-with-status.ts
@@ -4,14 +4,17 @@ import { useCallback, useMemo } from 'react';
import { HexString } from 'shared/keys';
import invariant from 'tiny-invariant';
import { compareLowercase, hasNoInterception } from 'utils';
-import { useExitRequestedKeysFromEvents } from './use-exit-requested-keys-from-events';
+//import { useExitRequestedKeysFromEvents } from './use-exit-requested-keys-from-events';
import { useKeysCLStatus } from './use-keys-cl-status';
import { useNetworkDuplicates } from './use-network-duplicates';
-import { useWithdrawnKeyIndexesFromEvents } from './use-withdrawn-key-indexes-from-events';
+//import { useWithdrawnKeyIndexesFromEvents } from './use-withdrawn-key-indexes-from-events';
import { useMergeSwr } from './useMergeSwr';
import { useNodeOperatorInfo } from './useNodeOperatorInfo';
import { useNodeOperatorKeys } from './useNodeOperatorKeys';
import { useNodeOperatorUnbondedKeys } from './useNodeOperatorUnbondedKeys';
+// DAPPNODE
+import { useWithdrawnKeyIndexesFromEvents } from 'dappnode/hooks/use-withdrawn-key-indexes-from-events-api';
+import { useExitRequestedKeysFromEvents } from 'dappnode/hooks/use-exit-requested-keys-from-events-api';
export type KeyWithStatus = {
key: HexString;
diff --git a/shared/hooks/use-show-rule.ts b/shared/hooks/use-show-rule.ts
index 92d8a41e..7cf36fce 100644
--- a/shared/hooks/use-show-rule.ts
+++ b/shared/hooks/use-show-rule.ts
@@ -1,3 +1,4 @@
+import { useECSanityCheck } from 'dappnode/hooks/use-ec-sanity-check';
import { useNodeOperatorContext } from 'providers/node-operator-provider';
import { useCallback } from 'react';
import {
@@ -17,7 +18,13 @@ export type ShowRule =
| 'HAS_REWARDS_ROLE'
| 'HAS_LOCKED_BOND'
| 'CAN_CREATE'
- | 'EL_STEALING_REPORTER';
+ | 'EL_STEALING_REPORTER'
+
+ // DAPPNODE
+ | 'IS_EXECUTION_LOADING'
+ | 'IS_EXECUTION_INSTALLED'
+ | 'IS_EXECUTION_SYNCED'
+ | 'EXECUTION_HAS_LOGS';
export const useShowRule = () => {
const { active: isConnectedWallet } = useAccount();
@@ -27,6 +34,9 @@ export const useShowRule = () => {
const { data: lockedBond } = useNodeOperatorLockAmount(nodeOperator?.id);
const canCreateNO = useCanCreateNodeOperator();
+ // DAPPNODE
+ const { isInstalled, isSynced, hasLogs } = useECSanityCheck();
+
return useCallback(
(condition: ShowRule): boolean => {
switch (condition) {
@@ -48,6 +58,14 @@ export const useShowRule = () => {
return !!lockedBond?.gt(0);
case 'EL_STEALING_REPORTER':
return !!isReportingRole;
+
+ // DAPPNODE
+ case 'IS_EXECUTION_INSTALLED':
+ return isInstalled;
+ case 'IS_EXECUTION_SYNCED':
+ return isSynced;
+ case 'EXECUTION_HAS_LOGS':
+ return hasLogs;
default:
return false;
}
@@ -59,6 +77,9 @@ export const useShowRule = () => {
invites?.length,
lockedBond,
isReportingRole,
+ isInstalled,
+ isSynced,
+ hasLogs,
],
);
};
diff --git a/shared/navigate/gates/gate-loaded.tsx b/shared/navigate/gates/gate-loaded.tsx
index 9fc2bce2..110380e3 100644
--- a/shared/navigate/gates/gate-loaded.tsx
+++ b/shared/navigate/gates/gate-loaded.tsx
@@ -3,6 +3,7 @@ import { FC, PropsWithChildren, ReactNode } from 'react';
import { useAccount, useCsmPaused, useCsmPublicRelease } from 'shared/hooks';
import { useCsmEarlyAdoption } from 'shared/hooks/useCsmEarlyAdoption';
import { SplashPage } from '../splash';
+import { ECScanningPage } from 'dappnode/fallbacks/ec-scanning-events';
type Props = {
fallback?: ReactNode;
@@ -10,7 +11,6 @@ type Props = {
};
export const GateLoaded: FC> = ({
- fallback = ,
additional,
children,
}) => {
@@ -20,6 +20,9 @@ export const GateLoaded: FC> = ({
const { isListLoading, active } = useNodeOperatorContext();
const { initialLoading: isEaLoading } = useCsmEarlyAdoption();
+ // DAPPNODE
+ const fallback = isListLoading ? : ;
+
const loading =
isPublicReleaseLoading ||
isPausedLoading ||
diff --git a/yarn.lock b/yarn.lock
index c0add1b3..82c3c397 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3760,6 +3760,69 @@
dependencies:
"@types/node" "*"
+"@types/d3-array@^3.0.3":
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5"
+ integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==
+
+"@types/d3-color@*":
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
+ integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
+
+"@types/d3-ease@^3.0.0":
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
+ integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
+
+"@types/d3-interpolate@^3.0.1":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
+ integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
+ dependencies:
+ "@types/d3-color" "*"
+
+"@types/d3-path@*":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.0.tgz#2b907adce762a78e98828f0b438eaca339ae410a"
+ integrity sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==
+
+"@types/d3-path@^1":
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.11.tgz#45420fee2d93387083b34eae4fe6d996edf482bc"
+ integrity sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==
+
+"@types/d3-scale@^4.0.2":
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb"
+ integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==
+ dependencies:
+ "@types/d3-time" "*"
+
+"@types/d3-shape@^1":
+ version "1.3.12"
+ resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259"
+ integrity sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==
+ dependencies:
+ "@types/d3-path" "^1"
+
+"@types/d3-shape@^3.1.0":
+ version "3.1.7"
+ resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.7.tgz#2b7b423dc2dfe69c8c93596e673e37443348c555"
+ integrity sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==
+ dependencies:
+ "@types/d3-path" "*"
+
+"@types/d3-time@*", "@types/d3-time@^3.0.0":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f"
+ integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==
+
+"@types/d3-timer@^3.0.0":
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
+ integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
+
"@types/debug@^4.1.7":
version "4.1.12"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
@@ -3935,6 +3998,14 @@
"@types/prop-types" "*"
csstype "^3.0.2"
+"@types/recharts@^1.8.29":
+ version "1.8.29"
+ resolved "https://registry.yarnpkg.com/@types/recharts/-/recharts-1.8.29.tgz#5e117521a65bf015b808350b45b65553ff5011f3"
+ integrity sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw==
+ dependencies:
+ "@types/d3-shape" "^1"
+ "@types/react" "*"
+
"@types/scheduler@*":
version "0.23.0"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.23.0.tgz#0a6655b3e2708eaabca00b7372fafd7a792a7b09"
@@ -5426,6 +5497,11 @@ clsx@^1.1.0, clsx@^1.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
+clsx@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
+ integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
+
cluster-key-slot@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
@@ -5743,6 +5819,77 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
+"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
+ version "3.2.4"
+ resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
+ integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
+ dependencies:
+ internmap "1 - 2"
+
+"d3-color@1 - 3":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
+ integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
+
+d3-ease@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
+ integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
+
+"d3-format@1 - 3":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
+ integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
+
+"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
+ integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
+ dependencies:
+ d3-color "1 - 3"
+
+d3-path@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
+ integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
+
+d3-scale@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
+ integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
+ dependencies:
+ d3-array "2.10.0 - 3"
+ d3-format "1 - 3"
+ d3-interpolate "1.2.0 - 3"
+ d3-time "2.1.1 - 3"
+ d3-time-format "2 - 4"
+
+d3-shape@^3.1.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
+ integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
+ dependencies:
+ d3-path "^3.1.0"
+
+"d3-time-format@2 - 4":
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
+ integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
+ dependencies:
+ d3-time "1 - 3"
+
+"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
+ integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
+ dependencies:
+ d3-array "2 - 3"
+
+d3-timer@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
+ integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
+
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -5812,6 +5959,11 @@ decamelize@^1.1.0, decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
+decimal.js-light@^2.4.1:
+ version "2.5.1"
+ resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
+ integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
+
decode-uri-component@^0.2.0, decode-uri-component@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
@@ -6639,7 +6791,7 @@ event-target-shim@^5.0.0:
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
-eventemitter3@^4.0.0, eventemitter3@^4.0.7:
+eventemitter3@^4.0.0, eventemitter3@^4.0.1, eventemitter3@^4.0.7:
version "4.0.7"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
@@ -6746,6 +6898,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+fast-equals@^5.0.1:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.2.2.tgz#885d7bfb079fac0ce0e8450374bce29e9b742484"
+ integrity sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==
+
fast-glob@^3.2.9, fast-glob@^3.3.1:
version "3.3.2"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
@@ -7423,6 +7580,11 @@ internal-slot@^1.0.4, internal-slot@^1.0.7:
hasown "^2.0.0"
side-channel "^1.0.4"
+"internmap@1 - 2":
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
+ integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
+
invariant@2, invariant@^2.2.2:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -9855,6 +10017,15 @@ react-jazzicon@^1.0.4:
dependencies:
mersenne-twister "^1.1.0"
+react-smooth@^4.0.4:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-4.0.4.tgz#a5875f8bb61963ca61b819cedc569dc2453894b4"
+ integrity sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==
+ dependencies:
+ fast-equals "^5.0.1"
+ prop-types "^15.8.1"
+ react-transition-group "^4.4.5"
+
react-toastify@7.0.4:
version "7.0.4"
resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-7.0.4.tgz#7d0b743f2b96f65754264ca6eae31911a82378db"
@@ -9862,7 +10033,7 @@ react-toastify@7.0.4:
dependencies:
clsx "^1.1.1"
-react-transition-group@4, react-transition-group@^4.4.2:
+react-transition-group@4, react-transition-group@^4.4.2, react-transition-group@^4.4.5:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==
@@ -9935,6 +10106,27 @@ real-require@^0.2.0:
resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78"
integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==
+recharts-scale@^0.4.4:
+ version "0.4.5"
+ resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9"
+ integrity sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==
+ dependencies:
+ decimal.js-light "^2.4.1"
+
+recharts@^2.15.1:
+ version "2.15.1"
+ resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.15.1.tgz#0941adf0402528d54f6d81997eb15840c893aa3c"
+ integrity sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==
+ dependencies:
+ clsx "^2.0.0"
+ eventemitter3 "^4.0.1"
+ lodash "^4.17.21"
+ react-is "^18.3.1"
+ react-smooth "^4.0.4"
+ recharts-scale "^0.4.4"
+ tiny-invariant "^1.3.1"
+ victory-vendor "^36.6.8"
+
redent@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
@@ -10969,7 +11161,7 @@ tiny-async-pool@^2.1.0:
resolved "https://registry.yarnpkg.com/tiny-async-pool/-/tiny-async-pool-2.1.0.tgz#3ec126568c18a7916912fb9fbecf812337ec6b84"
integrity sha512-ltAHPh/9k0STRQqaoUX52NH4ZQYAJz24ZAEwf1Zm+HYg3l9OXTWeqWKyYsHu40wF/F0rxd2N2bk5sLvX2qlSvg==
-tiny-invariant@^1.1.0:
+tiny-invariant@^1.1.0, tiny-invariant@^1.3.1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
@@ -11553,6 +11745,26 @@ vfile@^4.0.0:
unist-util-stringify-position "^2.0.0"
vfile-message "^2.0.0"
+victory-vendor@^36.6.8:
+ version "36.9.2"
+ resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.9.2.tgz#668b02a448fa4ea0f788dbf4228b7e64669ff801"
+ integrity sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==
+ dependencies:
+ "@types/d3-array" "^3.0.3"
+ "@types/d3-ease" "^3.0.0"
+ "@types/d3-interpolate" "^3.0.1"
+ "@types/d3-scale" "^4.0.2"
+ "@types/d3-shape" "^3.1.0"
+ "@types/d3-time" "^3.0.0"
+ "@types/d3-timer" "^3.0.0"
+ d3-array "^3.1.6"
+ d3-ease "^3.0.1"
+ d3-interpolate "^3.0.1"
+ d3-scale "^4.0.2"
+ d3-shape "^3.1.0"
+ d3-time "^3.0.0"
+ d3-timer "^3.0.1"
+
wagmi@0.12.19:
version "0.12.19"
resolved "https://registry.yarnpkg.com/wagmi/-/wagmi-0.12.19.tgz#5f5038330907f70c033ea51ef8a9136289567256"