diff --git a/.env.template b/.env.template index d9e2a17e..129b1ae4 100644 --- a/.env.template +++ b/.env.template @@ -12,6 +12,12 @@ REACT_APP_IS_MAINNET=true REACT_APP_FORTMATIC_KEY=pk_test_D113D979E0D3508F # string REACT_APP_CONTRACT_ADDRESS=0x00000000219ab540356cBB839Cbe05303d7705Fa +# string +# TODO: Replace with Mainnet address +REACT_APP_COMPOUNDING_CONTRACT_ADDRESS=0x00431F263cE400f4455c2dCf564e53007Ca4bbBb +# string +# TODO: Replace with Mainnet address +REACT_APP_WITHDRAWAL_CONTRACT_ADDRESS=0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA # number REACT_APP_ETH_REQUIREMENT=524288 # number @@ -32,32 +38,45 @@ REACT_APP_TEKU_INSTALLATION_URL=https://docs.teku.consensys.io/get-started/insta # # Testnet only # -# # true, false -# REACT_APP_IS_MAINNET=false -# # string -# REACT_APP_CONTRACT_ADDRESS=0x4242424242424242424242424242424242424242 -# # string -# REACT_APP_RPC_URL=https://rpc.holesky.ethpandaops.io -# # string -# REACT_APP_BEACONCHAIN_URL=https://holesky.beaconcha.in -# # string -# REACT_APP_EL_EXPLORER_URL=https://holesky.beaconcha.in -# # string -# REACT_APP_TUTORIAL_URL=https://notes.ethereum.org/@launchpad/holesky -# # string (if `REACT_APP_IS_MAINNET=true`, it's the active public testnet name; otherwise, it's the testnet name of this website) -# REACT_APP_TESTNET_LAUNCHPAD_NAME=Holesky -# # number -# REACT_APP_ETH_DEPOSIT_OFFSET=0 -# # string -# REACT_APP_FAUCET_URL=https://www.holeskyfaucet.io/ +# true, false REACT_APP_IS_MAINNET=false +# string (if `REACT_APP_IS_MAINNET=true`, it's the active public testnet name; otherwise, it's the testnet name of this website) +REACT_APP_TESTNET_LAUNCHPAD_NAME=Holesky +# string REACT_APP_CONTRACT_ADDRESS=0x4242424242424242424242424242424242424242 -REACT_APP_ETH_REQUIREMENT=524288 -REACT_APP_TESTNET_LAUNCHPAD_NAME=devnet5 -REACT_APP_EL_EXPLORER_URL=https://explorer.pectra-devnet-5.ethpandaops.io +# string +REACT_APP_RPC_URL=https://rpc.holesky.ethpandaops.io +# string +REACT_APP_BEACONCHAIN_URL=https://holesky.beaconcha.in +# string +REACT_APP_EL_EXPLORER_URL=https://holesky.beaconcha.in +# string +REACT_APP_TUTORIAL_URL=https://notes.ethereum.org/@launchpad/holesky +# number +REACT_APP_ETH_DEPOSIT_OFFSET=0 +# string +REACT_APP_FAUCET_URL=https://www.holeskyfaucet.io/ +# string +REACT_APP_COMPOUNDING_CONTRACT_ADDRESS= # TODO: Replace with Holesky address +# string +REACT_APP_WITHDRAWAL_CONTRACT_ADDRESS= # TODO: Replace with Holesky address +# string +REACT_APP_RPC_URL=https://rpc.holesky.ethpandaops.io +# string +REACT_APP_BEACONCHAIN_URL=https://holesky.beaconcha.in +# string +REACT_APP_TUTORIAL_URL=https://notes.ethereum.org/@launchpad/holesky +# number +REACT_APP_ETH_DEPOSIT_OFFSET=0 + +# +# Devnet only +# REACT_APP_GENESIS_FORK_VERSION=0x10710240 +REACT_APP_TESTNET_LAUNCHPAD_NAME=devnet5 REACT_APP_RPC_URL=https://rpc.pectra-devnet-5.ethpandaops.io REACT_APP_BEACONCHAIN_URL=https://pectra-devnet-5.beaconcha.in/ -REACT_APP_IS_MERGE=true +REACT_APP_EL_EXPLORER_URL=https://explorer.pectra-devnet-5.ethpandaops.io +REACT_APP_FAUCET_URL=https://faucet.pectra-devnet-5.ethpandaops.io/ REACT_APP_COMPOUNDING_CONTRACT_ADDRESS=0x00431F263cE400f4455c2dCf564e53007Ca4bbBb -REACT_APP_WITHDRAWAL_CONTRACT_ADDRESS=0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA \ No newline at end of file +REACT_APP_WITHDRAWAL_CONTRACT_ADDRESS=0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA diff --git a/package.json b/package.json index 1a3f1cdd..e995d289 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "tiny-invariant": "^1.1.0", "typescript": "^3.8.3", "web3": "^1.7.5", + "web3-core": "^1.7.5", "web3-utils": "^1.2.6", "yarn": "^1.22.13" }, diff --git a/src/Routes.tsx b/src/Routes.tsx index 84727e41..68899ea2 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl'; import { supportedLanguages } from './intl'; import { AcknowledgementPage, + ActionsPage, CongratulationsPage, ConnectWalletPage, SelectClientPage, @@ -70,6 +71,7 @@ export enum routesEnum { languagesPage = '/languages', withdrawals = '/withdrawals', btecGuide = '/btec', + actionsPage = '/validator-actions', } const routes: RouteType[] = [ { @@ -126,6 +128,7 @@ const routes: RouteType[] = [ { path: routesEnum.topUpPage, exact: true, component: TopUpPage }, { path: routesEnum.withdrawals, exact: true, component: Withdrawals }, { path: routesEnum.btecGuide, exact: true, component: BtecGuide }, + { path: routesEnum.actionsPage, exact: true, component: ActionsPage }, { path: routesEnum.landingPage, exact: true, component: LandingPage }, // NOTE: this wildcard route must be the last index of the routes array { path: routesEnum.notFoundPage, component: NotFoundPage }, diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx index 9e33c1b5..c11d9317 100644 --- a/src/components/AppBar.tsx +++ b/src/components/AppBar.tsx @@ -300,6 +300,16 @@ const _AppBar = ({ location }: RouteComponentProps) => { + + + + + void; allowDecimals?: boolean; + maxValue?: number; } export const NumberInput = ({ value, setValue, allowDecimals, + maxValue, }: Props): JSX.Element => { const handleManualInput = (e: any) => { const val = e.target.value; if (allowDecimals) { setValue(val); } else { - setValue(val.replace(/\./g, '')); // remove "." to force integer input; + // remove "." to force integer input; + setValue(val.replace(/\./g, '')); } }; const decrement = () => { - if (value > 0) setValue(+value - 1); + if (value > 0) { + setValue(Math.max(0, +value - 1)); + } }; - const increment = () => setValue(+value + 1); + const increment = () => { + const newValue = value + 1; + setValue(maxValue !== undefined ? Math.min(newValue, maxValue) : newValue); + }; return ( - + diff --git a/src/components/Select.tsx b/src/components/Select.tsx new file mode 100644 index 00000000..29c72388 --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,169 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import styled from 'styled-components'; +import { Checkmark, FormDown, FormUp } from 'grommet-icons'; + +const Container = styled.div` + position: relative; + width: 100%; + max-width: 380px; +`; + +const Trigger = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + border: 1px solid #ccc; + background-color: #fff; + cursor: pointer; +`; + +const Content = styled.div` + position: absolute; + top: 100%; + inset-inline: 0; + background-color: #fff; + border: 1px solid #ccc; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-height: 200px; + overflow-y: auto; + z-index: 1000; +`; + +const Item = styled.div` + padding: 0.5rem 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + &:hover { + background-color: #f0f0f0; + } + + &[data-selected='true'] { + background-color: #e0e0e0; + } +`; + +const SearchInput = styled.input` + width: 100%; + padding: 0.5rem 1rem; + border: none; + border-bottom: 1px solid #ccc; + outline: none; +`; + +export type Option = { + value: string; +} & ( + | { + label: string; + searchContext?: string; + } + | { + label: React.ReactNode; + searchContext: string; + } +); + +export type SelectProps = { + options: Option[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; +}; + +const Select = ({ + options, + value, + onChange, + placeholder, + searchPlaceholder, +}: SelectProps) => { + const { formatMessage } = useIntl(); + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + + const handleSelect = (choice: string) => { + onChange(choice); + setIsOpen(false); + setSearch(''); + }; + + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const selectedOption = options.find(option => option.value === value); + + const filteredOptions = options.filter(option => { + const hasSearchContext = 'searchContext' in option; + const hasStringLabel = typeof option.label === 'string'; + let fullSearchContext: string; + + if (hasStringLabel && hasSearchContext) { + fullSearchContext = `${option.searchContext}${option.label}`; + } else if (hasSearchContext) { + fullSearchContext = option.searchContext!; + } else { + fullSearchContext = option.label as string; + } + return fullSearchContext.toLowerCase().includes(search.toLowerCase()); + }); + + return ( + + setIsOpen(!isOpen)}> + {selectedOption?.label || placeholder} + {isOpen ? : } + + {isOpen && ( + + setSearch(e.target.value)} + placeholder={ + searchPlaceholder || + formatMessage({ defaultMessage: 'Type to filter' }) + } + /> + {filteredOptions.map(option => ( + handleSelect(option.value)} + > + {option.label} + + + ))} + + )} + + ); +}; + +export default Select; diff --git a/src/pages/TopUp/components/TransactionProgress.tsx b/src/components/TransactionStatusModal/TransactionProgress.tsx similarity index 91% rename from src/pages/TopUp/components/TransactionProgress.tsx rename to src/components/TransactionStatusModal/TransactionProgress.tsx index a2d555ba..8be34c69 100644 --- a/src/pages/TopUp/components/TransactionProgress.tsx +++ b/src/components/TransactionStatusModal/TransactionProgress.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import styled from 'styled-components'; import { Checkmark, Close } from 'grommet-icons'; -import Spinner from '../../../components/Spinner'; -import { Text } from '../../../components/Text'; -import { stepStatus } from '../types'; +import Spinner from '../Spinner'; +import { Text } from '../Text'; +import { stepStatus } from './types'; const Container = styled.div` justify-content: space-around; @@ -110,7 +110,7 @@ interface TransactionProgressProps { confirmOnChainStatus: stepStatus; } -const TransactionProgress: React.FC = ({ +export const TransactionProgress: React.FC = ({ signTxStatus, confirmOnChainStatus, }) => { @@ -131,5 +131,3 @@ const TransactionProgress: React.FC = ({ ); }; - -export default TransactionProgress; diff --git a/src/pages/TopUp/components/TopUpTransactionModal.tsx b/src/components/TransactionStatusModal/TransactionStatusModal.tsx similarity index 75% rename from src/pages/TopUp/components/TopUpTransactionModal.tsx rename to src/components/TransactionStatusModal/TransactionStatusModal.tsx index d93542c3..4d58d7d0 100644 --- a/src/pages/TopUp/components/TopUpTransactionModal.tsx +++ b/src/components/TransactionStatusModal/TransactionStatusModal.tsx @@ -1,21 +1,23 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { FormattedMessage } from 'react-intl'; import { Box, Layer } from 'grommet'; -import { Heading } from '../../../components/Heading'; -import { Text } from '../../../components/Text'; -import TransactionProgress from './TransactionProgress'; -import { stepStatus, TransactionStatus } from '../types'; -import { Button } from '../../../components/Button'; -import { EL_TRANSACTION_URL } from '../../../utils/envVars'; -import { Link } from '../../../components/Link'; +import { Heading } from '../Heading'; +import { Text } from '../Text'; +import { TransactionProgress } from './TransactionProgress'; +import { stepStatus, TransactionStatus } from './types'; +import { Button } from '../Button'; +import { EL_TRANSACTION_URL } from '../../utils/envVars'; +import { Link } from '../Link'; interface TopUpTransactionModalProps { + headerMessage: string | ReactNode; onClose: () => void; transactionStatus: TransactionStatus; txHash: string; } -const TopUpTransactionModal: React.FC = ({ +export const TransactionStatusModal: React.FC = ({ + headerMessage, onClose, transactionStatus, txHash, @@ -54,7 +56,14 @@ const TopUpTransactionModal: React.FC = ({ - + {typeof headerMessage === 'string' ? ( + + ) : ( + headerMessage + )} = ({ ); }; - -export default TopUpTransactionModal; diff --git a/src/components/TransactionStatusModal/index.ts b/src/components/TransactionStatusModal/index.ts new file mode 100644 index 00000000..13aead32 --- /dev/null +++ b/src/components/TransactionStatusModal/index.ts @@ -0,0 +1,3 @@ +export * from './TransactionStatusModal'; +export * from './TransactionProgress'; +export * from './types'; diff --git a/src/components/TransactionStatusModal/types.ts b/src/components/TransactionStatusModal/types.ts new file mode 100644 index 00000000..173bf98c --- /dev/null +++ b/src/components/TransactionStatusModal/types.ts @@ -0,0 +1,9 @@ +export type TransactionStatus = + | 'not_started' + | 'waiting_user_confirmation' + | 'user_rejected' + | 'confirm_on_chain' + | 'error' + | 'success'; + +export type stepStatus = 'loading' | 'staged' | 'complete' | 'error'; diff --git a/src/intl/compiled/en.json b/src/intl/compiled/en.json index 161f2623..73d9053f 100644 --- a/src/intl/compiled/en.json +++ b/src/intl/compiled/en.json @@ -27,6 +27,20 @@ "value": " (handling proof-of-stake consensus tasks, formerly 'Eth2' or 'Ethereum 2.0')." } ], + "+3RpL0": [ + { + "type": 0, + "value": "Partial Withdraw of " + }, + { + "type": 1, + "value": "amount" + }, + { + "type": 1, + "value": "ticker" + } + ], "+3gmIR": [ { "type": 0, @@ -195,6 +209,16 @@ "value": "withdrawable epoch" } ], + "/gME+2": [ + { + "type": 0, + "value": "Which validator would you like to consolidate into " + }, + { + "type": 1, + "value": "index" + } + ], "/hEGaO": [ { "type": 0, @@ -915,12 +939,6 @@ "value": "Become a validator" } ], - "3QmqFf": [ - { - "type": 0, - "value": "Top-up transaction" - } - ], "3SOKyg": [ { "type": 0, @@ -947,6 +965,12 @@ "value": "Reth Audit Report" } ], + "3hmlu1": [ + { + "type": 0, + "value": "Select a validator" + } + ], "3jhjXW": [ { "type": 0, @@ -1169,6 +1193,16 @@ "value": "Teku installation documentation" } ], + "5OjDXz": [ + { + "type": 0, + "value": "Withdrawal Credentails: " + }, + { + "type": 1, + "value": "credentials" + } + ], "5UH1sh": [ { "type": 0, @@ -1593,6 +1627,16 @@ "value": "Do not turn your validator off until this epoch is reached." } ], + "8hf1a0": [ + { + "type": 0, + "value": "Fully exit validator " + }, + { + "type": 1, + "value": "index" + } + ], "8k1RjX": [ { "type": 1, @@ -1639,6 +1683,12 @@ "value": " - epoch at which your validator funds are eligible for a full withdrawal during the next validator sweep." } ], + "8uE+mp": [ + { + "type": 0, + "value": "Filter by index or pubkey" + } + ], "8uq59n": [ { "type": 0, @@ -2433,6 +2483,16 @@ "value": "The key concept is the following:" } ], + "FFYAkU": [ + { + "type": 0, + "value": "Activation Epoch: " + }, + { + "type": 1, + "value": "epoch" + } + ], "FM92S0": [ { "type": 0, @@ -2913,6 +2973,20 @@ "value": "Beacon chain push withdrawals as operations" } ], + "IsyY5V": [ + { + "type": 0, + "value": "Upgrade validator " + }, + { + "type": 1, + "value": "index" + }, + { + "type": 0, + "value": " to compounding" + } + ], "IwrKbS": [ { "type": 0, @@ -3199,6 +3273,20 @@ "value": "View the Staking Checklist" } ], + "Kkn4j7": [ + { + "type": 0, + "value": "You can learn how to perform this update by viewing the " + }, + { + "type": 1, + "value": "LINK" + }, + { + "type": 0, + "value": " section." + } + ], "KqGKRA": [ { "type": 0, @@ -3307,6 +3395,12 @@ "value": "Bulgarian" } ], + "Ll/uYX": [ + { + "type": 0, + "value": "Please connect to a new wallet" + } + ], "LlClXS": [ { "type": 0, @@ -3485,6 +3579,24 @@ "value": "Bad validator behavior" } ], + "MpbZo9": [ + { + "type": 0, + "value": "Consolidate " + }, + { + "type": 1, + "value": "index" + }, + { + "type": 0, + "value": " into " + }, + { + "type": 1, + "value": "otherIndex" + } + ], "N0rwnt": [ { "type": 1, @@ -4333,6 +4445,12 @@ "value": "Visit this site on desktop to become a validator." } ], + "TTbBhG": [ + { + "type": 0, + "value": "This validator has Type 0 (0x00) credentials and must be updated in order to perform validator actions." + } + ], "TZtYuA": [ { "type": 0, @@ -4691,6 +4809,20 @@ "value": "Has the block explorer named the contract?" } ], + "X0yQPs": [ + { + "type": 0, + "value": "Are you sure you want to exit validator " + }, + { + "type": 1, + "value": "index" + }, + { + "type": 0, + "value": "?" + } + ], "X3NiFM": [ { "type": 0, @@ -4813,6 +4945,12 @@ "value": "~25 minutes" } ], + "XjMz3s": [ + { + "type": 0, + "value": "No validators were discovered for the provided account." + } + ], "XmddSV": [ { "type": 0, @@ -4911,6 +5049,12 @@ "value": "Keys" } ], + "YHS8Mc": [ + { + "type": 0, + "value": "Type to filter" + } + ], "YJI2xv": [ { "type": 0, @@ -5489,6 +5633,12 @@ "value": "Back" } ], + "d3fRf2": [ + { + "type": 0, + "value": "There was an error trying to get the validators for the provided wallet. Please try again or seek assistance if this error continues." + } + ], "d4g3yx": [ { "type": 0, @@ -5775,6 +5925,16 @@ "value": "Information for validator operators regarding staking withdrawals." } ], + "fVnFri": [ + { + "type": 0, + "value": "Validator Index: " + }, + { + "type": 1, + "value": "index" + } + ], "fZwn+P": [ { "type": 0, @@ -6027,6 +6187,12 @@ "value": "Desktop only" } ], + "hIQWYg": [ + { + "type": 0, + "value": "Validator Actions" + } + ], "hUmIwN": [ { "type": 0, @@ -6189,6 +6355,12 @@ "value": "If you do not have a testnet folder it is likely you have not built and run Nimbus correctly. Run the make command again." } ], + "iZW+Wc": [ + { + "type": 1, + "value": "headerMessage" + } + ], "iZuIgB": [ { "type": 0, @@ -6371,6 +6543,12 @@ "value": "Nimbus validator documentation" } ], + "jmoYdI": [ + { + "type": 0, + "value": "Please understand that once you begin the exit process you will not be able to undo the decision." + } + ], "jp9OG2": [ { "type": 0, @@ -6603,6 +6781,12 @@ "value": "You're on the testnet" } ], + "l0+Knx": [ + { + "type": 0, + "value": "You will be asked to sign a transaction to exit your validator." + } + ], "l4nLft": [ { "type": 0, @@ -6827,6 +7011,36 @@ "value": "Since the successful transition to proof-of-stake via The Merge, Ethereum is fully secured by proof-of-stake validators. By running a validator, you'll be helping to secure the Ethereum network." } ], + "mHSz8x": [ + { + "type": 0, + "value": "Please select how much you would like to withdraw. Due to requiring a minimum balance of " + }, + { + "type": 1, + "value": "minBalance" + }, + { + "type": 1, + "value": "ticker" + }, + { + "type": 0, + "value": " for the validator to operate, you will be able to withdraw a maximum of " + }, + { + "type": 1, + "value": "maxAmount" + }, + { + "type": 1, + "value": "ticker" + }, + { + "type": 0, + "value": "." + } + ], "mROPz+": [ { "type": 0, @@ -7333,6 +7547,16 @@ "value": "Alternative node tooling:" } ], + "pnFNq9": [ + { + "type": 0, + "value": "Public Key: " + }, + { + "type": 1, + "value": "pubkey" + } + ], "pnxB8A": [ { "type": 0, @@ -7445,6 +7669,24 @@ "value": "Exit epoch and withdrawable epoch" } ], + "qIB0uh": [ + { + "type": 0, + "value": "Your validator must have a minimum balance of " + }, + { + "type": 1, + "value": "minBalance" + }, + { + "type": 1, + "value": "ticker" + }, + { + "type": 0, + "value": " to withdraw. If you want to withdraw the entirety of the validator balance you must exit." + } + ], "qK4+NQ": [ { "type": 0, @@ -7675,6 +7917,12 @@ "value": "Launchpad summary" } ], + "rywDJ5": [ + { + "type": 0, + "value": "Connect your wallet to retrieve validators with corresponding withdrawal credentials to upgrade to compounding, consolidate, partially withdraw, or force an exit" + } + ], "s6gImC": [ { "type": 0, @@ -8185,6 +8433,16 @@ "value": " as soon as possible. And join the EthStaker community for support and discussion with fellow validators." } ], + "vclhrb": [ + { + "type": 0, + "value": "Balance: " + }, + { + "type": 1, + "value": "balance" + } + ], "vj7TN7": [ { "type": 0, @@ -8213,6 +8471,16 @@ "value": "This occurs 256 epochs after the exit epoch, which takes ~27.3 hours." } ], + "w2THbw": [ + { + "type": 0, + "value": "Your validator has a balance of " + }, + { + "type": 1, + "value": "balance" + } + ], "w6qYSU": [ { "type": 0, @@ -8241,6 +8509,12 @@ "value": "Initial setup" } ], + "wL7VAE": [ + { + "type": 0, + "value": "Actions" + } + ], "wawegX": [ { "type": 0, @@ -8385,6 +8659,12 @@ "value": "Install python3.7+" } ], + "xgSoMq": [ + { + "type": 0, + "value": "How much would you like to withdraw?" + } + ], "xh7oVn": [ { "type": 0, diff --git a/src/intl/en.json b/src/intl/en.json index 5384e3df..f765f6b6 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -6,6 +6,9 @@ "description": "{executionLayer} is a link labeled 'execution layer'. {consensusLayer} is a link labeled 'consensus layer'", "message": "Ethereum consists of the {executionLayer} (handling transactions and execution, formerly 'Eth1'), and the {consensusLayer} (handling proof-of-stake consensus tasks, formerly 'Eth2' or 'Ethereum 2.0')." }, + "+3RpL0": { + "message": "Partial Withdraw of {amount}{ticker}" + }, "+3gmIR": { "message": "Lodestar is a Typescript ecosystem for Ethereum consensus, developed by ChainSafe Systems. Our flagship products are our production-capable beacon chain and validator client. Lodestar’s niche is in its implementation language, TypeScript. Our software and tooling is uniquely situated as the go-to for researchers and developers for rapid prototyping." }, @@ -84,6 +87,9 @@ "/e+qAC": { "message": "withdrawable epoch" }, + "/gME+2": { + "message": "Which validator would you like to consolidate into {index}" + }, "/hEGaO": { "message": "This validator account has a maximum effective balance of {maxEffectiveBalanceEther} {TICKER_NAME}. You only need to top up {maxTopupValue} {TICKER_NAME} to max out your effective balance." }, @@ -339,9 +345,6 @@ "3Q43SM": { "message": "Become a validator" }, - "3QmqFf": { - "message": "Top-up transaction" - }, "3SOKyg": { "message": "These terminology updates only change naming conventions; this does not alter Ethereum's goals or roadmap." }, @@ -351,6 +354,9 @@ "3fRpn0": { "message": "Reth Audit Report" }, + "3hmlu1": { + "message": "Select a validator" + }, "3jhjXW": { "message": "Check out the following guide to properly configure Engine JSON-RPC API for your consensus client." }, @@ -427,6 +433,9 @@ "5HJLBU": { "message": "Teku installation documentation" }, + "5OjDXz": { + "message": "Withdrawal Credentails: {credentials}" + }, "5UH1sh": { "message": "Romanian" }, @@ -569,6 +578,9 @@ "8echXf": { "message": "Do not turn your validator off until this epoch is reached." }, + "8hf1a0": { + "message": "Fully exit validator {index}" + }, "8k1RjX": { "message": "{NETWORK_NAME} testnet" }, @@ -584,6 +596,9 @@ "8rxaqc": { "message": "{withdrawableEpochLabel} - epoch at which your validator funds are eligible for a full withdrawal during the next validator sweep." }, + "8uE+mp": { + "message": "Filter by index or pubkey" + }, "8uq59n": { "message": "Signal your intent to exit staking by signing and broadcasting a voluntary exit message to the network using your validator keys and validator client" }, @@ -918,6 +933,9 @@ "FEbVwB": { "message": "The key concept is the following:" }, + "FFYAkU": { + "message": "Activation Epoch: {epoch}" + }, "FM92S0": { "message": "Note: the Beacon Chain may take several minutes to verify your deposit" }, @@ -1113,6 +1131,9 @@ "InUvmv": { "message": "Beacon chain push withdrawals as operations" }, + "IsyY5V": { + "message": "Upgrade validator {index} to compounding" + }, "IwrKbS": { "description": "{impact} shows 'impact of effective balance' and links to article on 'Effective Balance'", "message": "It depends. In addition to the {impact} there are two important scenarios to be aware of:" @@ -1219,6 +1240,9 @@ "KhJBVZ": { "message": "View the Staking Checklist" }, + "Kkn4j7": { + "message": "You can learn how to perform this update by viewing the {LINK} section." + }, "KqGKRA": { "message": "Once the process is complete, you should see the following:" }, @@ -1269,6 +1293,9 @@ "Ljo9dF": { "message": "Bulgarian" }, + "Ll/uYX": { + "message": "Please connect to a new wallet" + }, "LlClXS": { "message": "Thank you for supporting the Ethereum network!" }, @@ -1345,6 +1372,9 @@ "Mor0Ih": { "message": "Bad validator behavior" }, + "MpbZo9": { + "message": "Consolidate {index} into {otherIndex}" + }, "N0rwnt": { "message": "{depositFileName} isn't a valid deposit_data JSON file. Try again." }, @@ -1661,6 +1691,9 @@ "TMw9oz": { "message": "Visit this site on desktop to become a validator." }, + "TTbBhG": { + "message": "This validator has Type 0 (0x00) credentials and must be updated in order to perform validator actions." + }, "TZtYuA": { "message": "The terms 'Eth1' and 'Eth2' have been deprecated with The Merge. Since successfully transitioning to proof-of-stake via The Merge, there are no longer two distinct Ethereum networks; there is only Ethereum." }, @@ -1804,6 +1837,9 @@ "X/fKmY": { "message": "Has the block explorer named the contract?" }, + "X0yQPs": { + "message": "Are you sure you want to exit validator {index}?" + }, "X3NiFM": { "message": "proof-of-custody game" }, @@ -1845,6 +1881,9 @@ "XcItS7": { "message": "~25 minutes" }, + "XjMz3s": { + "message": "No validators were discovered for the provided account." + }, "XmddSV": { "message": "Build from source" }, @@ -1873,6 +1912,9 @@ "YFT4rG": { "message": "Keys" }, + "YHS8Mc": { + "message": "Type to filter" + }, "YJI2xv": { "message": "A decision tree is followed to determine what type of withdrawal will be initiated. If the validator being checked has ETH that is eligible to be withdrawn, it is added to the withdrawal queue. If there isn’t, the account is skipped." }, @@ -2102,6 +2144,9 @@ "cyR7Kh": { "message": "Back" }, + "d3fRf2": { + "message": "There was an error trying to get the validators for the provided wallet. Please try again or seek assistance if this error continues." + }, "d4g3yx": { "message": "Step 2: Generate deposit keys using the Wagyu Key Gen app" }, @@ -2203,6 +2248,9 @@ "fSi2R/": { "message": "Information for validator operators regarding staking withdrawals." }, + "fVnFri": { + "message": "Validator Index: {index}" + }, "fZwn+P": { "message": "After client installation, ensure you are fully synced before submitting your staking deposit. This can take several days." }, @@ -2300,6 +2348,9 @@ "hG0uP4": { "message": "Desktop only" }, + "hIQWYg": { + "message": "Validator Actions" + }, "hUmIwN": { "message": "Validator balance is over {EJECTION_PRICE}, but effective balance is low ({effectiveBalanceEther}). Adding {MIN_TOPUP_VALUE} {TICKER_NAME} (the minimum allowed by the deposit contract) will max out your effective balance." }, @@ -2353,6 +2404,9 @@ "iXbM1G": { "message": "If you do not have a testnet folder it is likely you have not built and run Nimbus correctly. Run the make command again." }, + "iZW+Wc": { + "message": "{headerMessage}" + }, "iZuIgB": { "message": "Decompress the file you just downloaded" }, @@ -2429,6 +2483,9 @@ "jmKQY9": { "message": "Nimbus validator documentation" }, + "jmoYdI": { + "message": "Please understand that once you begin the exit process you will not be able to undo the decision." + }, "jp9OG2": { "description": "{ethClientType} injects \"execution\" or \"consensus\" depending on step", "message": "Choose {ethClientType} client" @@ -2520,6 +2577,9 @@ "l/uCcX": { "message": "You're on the testnet" }, + "l0+Knx": { + "message": "You will be asked to sign a transaction to exit your validator." + }, "l4nLft": { "message": "I know how to check that I am sending my {TICKER_NAME} into the correct deposit contract and will do so." }, @@ -2594,6 +2654,9 @@ "mH6bSl": { "message": "Since the successful transition to proof-of-stake via The Merge, Ethereum is fully secured by proof-of-stake validators. By running a validator, you'll be helping to secure the Ethereum network." }, + "mHSz8x": { + "message": "Please select how much you would like to withdraw. Due to requiring a minimum balance of {minBalance}{ticker} for the validator to operate, you will be able to withdraw a maximum of {maxAmount}{ticker}." + }, "mROPz+": { "message": "I provided an Ethereum address when generating my {depositData} file before depositing where I would like all validator rewards and withdrawals to be deposited into." }, @@ -2808,6 +2871,9 @@ "plXBe0": { "message": "Alternative node tooling:" }, + "pnFNq9": { + "message": "Public Key: {pubkey}" + }, "pnxB8A": { "message": "Nimbus looks for keystores in your validators folder." }, @@ -2839,6 +2905,9 @@ "qBorVY": { "message": "Exit epoch and withdrawable epoch" }, + "qIB0uh": { + "message": "Your validator must have a minimum balance of {minBalance}{ticker} to withdraw. If you want to withdraw the entirety of the validator balance you must exit." + }, "qK4+NQ": { "message": "The terminal" }, @@ -2926,6 +2995,9 @@ "rswUPu": { "message": "Launchpad summary" }, + "rywDJ5": { + "message": "Connect your wallet to retrieve validators with corresponding withdrawal credentials to upgrade to compounding, consolidate, partially withdraw, or force an exit" + }, "s6gImC": { "message": "Teku can also be configured via a YAML file which is passed in via a few different ways." }, @@ -3099,12 +3171,18 @@ "description": "{stakerChecklist} = 'Staker Checklist' bolded to draw attention", "message": "Be sure to complete the {stakerChecklist} as soon as possible. And join the EthStaker community for support and discussion with fellow validators." }, + "vclhrb": { + "message": "Balance: {balance}" + }, "vj7TN7": { "message": "This value is known as the maximum effective balance (the maximum amount of ETH that contributes to your stake). Older account types (Type 0, or Type 1) have an effective balance limited to {MIN_ACTIVATION_BALANCE}. In these accounts, any balance over {MIN_ACTIVATION_BALANCE} does not contribute to your principle, nor compound earnings." }, "vz8WK7": { "message": "This occurs 256 epochs after the exit epoch, which takes ~27.3 hours." }, + "w2THbw": { + "message": "Your validator has a balance of {balance}" + }, "w6qYSU": { "message": "Run the following command to launch the app" }, @@ -3118,6 +3196,9 @@ "wJy3tO": { "message": "Initial setup" }, + "wL7VAE": { + "message": "Actions" + }, "wawegX": { "message": "fishing rod" }, @@ -3185,6 +3266,9 @@ "xbet2w": { "message": "Install python3.7+" }, + "xgSoMq": { + "message": "How much would you like to withdraw?" + }, "xh7oVn": { "message": "Those looking to exit their validator from staking and withdrawal their ETH should check out the guide below that matches your setup:" }, diff --git a/src/pages/Actions/ActionUtils.ts b/src/pages/Actions/ActionUtils.ts new file mode 100644 index 00000000..8fc46178 --- /dev/null +++ b/src/pages/Actions/ActionUtils.ts @@ -0,0 +1,91 @@ +import BigNumber from 'bignumber.js'; +import Web3 from 'web3'; +import { TransactionConfig } from 'web3-core'; +import { + COMPOUNDING_CONTRACT_ADDRESS, + WITHDRAWAL_CONTRACT_ADDRESS, +} from '../../utils/envVars'; + +const getRequiredFee = (queueLength: BigNumber): BigNumber => { + let i = new BigNumber(1); + let output = new BigNumber(0); + let numeratorAccum = new BigNumber(1).times(17); // factor * denominator + + while (numeratorAccum.gt(0)) { + output = output.plus(numeratorAccum); + numeratorAccum = numeratorAccum.times(queueLength).dividedBy(i.times(17)); + i = i.plus(1); + } + + return output.dividedBy(17); +}; + +// https://eips.ethereum.org/EIPS/eip-7251#fee-calculation +export const getCompoundingFee = async (web3: Web3): Promise => { + const queueLengthHex = await web3.eth.getStorageAt( + COMPOUNDING_CONTRACT_ADDRESS!, + 0 + ); + + if (!queueLengthHex) { + throw new Error('Unable to get compounding queue length'); + } + + const queueLength = new BigNumber(queueLengthHex); + + return getRequiredFee(queueLength); +}; + +// https://eips.ethereum.org/EIPS/eip-7002#fee-calculation +export const getWithdrawalFee = async (web3: Web3): Promise => { + const queueLengthHex = await web3.eth.getStorageAt( + WITHDRAWAL_CONTRACT_ADDRESS!, + 0 + ); + + if (!queueLengthHex) { + throw new Error('Unable to get withdrawal queue length'); + } + + const queueLength = new BigNumber(queueLengthHex); + + return getRequiredFee(queueLength); +}; + +export const generateCompoundParams = async ( + web3: Web3, + address: string, + pubkeyA: string, + pubkeyB: string +): Promise => { + const fee = await getCompoundingFee(web3); + + // https://eips.ethereum.org/EIPS/eip-7251#add-consolidation-request + return { + to: COMPOUNDING_CONTRACT_ADDRESS, + from: address, + // calldata (96 bytes): sourceValidator.pubkey (48 bytes) + targetValidator.pubkey (48 bytes) + data: '0x' + pubkeyA.substring(2) + pubkeyB.substring(2), + value: fee.toString(), + gas: 200000, + }; +}; + +export const generateWithdrawalParams = async ( + web3: Web3, + address: string, + pubkey: string, + amount: number +): Promise => { + const fee = await getWithdrawalFee(web3); + + // https://eips.ethereum.org/EIPS/eip-7002#add-withdrawal-request + return { + to: WITHDRAWAL_CONTRACT_ADDRESS, + from: address, + // calldata (56 bytes): sourceValidator.pubkey (48 bytes) + amount (8 bytes) + data: '0x' + pubkey.substring(2) + amount.toString(16).padStart(16, '0'), + value: fee.toString(), + gas: 200000, + }; +}; diff --git a/src/pages/Actions/components/Consolidate.tsx b/src/pages/Actions/components/Consolidate.tsx new file mode 100644 index 00000000..9c9ea02d --- /dev/null +++ b/src/pages/Actions/components/Consolidate.tsx @@ -0,0 +1,148 @@ +import { AbstractConnector } from '@web3-react/abstract-connector'; +import { useWeb3React } from '@web3-react/core'; +import { Box, Button, Heading, Layer } from 'grommet'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import Web3 from 'web3'; +import { Validator } from '../types'; +import { + TransactionStatus, + TransactionStatusModal, +} from '../../../components/TransactionStatusModal'; +import Select from '../../../components/Select'; +import { generateCompoundParams } from '../ActionUtils'; + +interface Props { + validator: Validator; + validators: Validator[]; +} + +const Consolidate: React.FC = ({ validator, validators }) => { + const { connector, account } = useWeb3React(); + + const [selectedValidator, setSelectedValidator] = useState( + null + ); + const [showSelectValidatorModal, setShowSelectValidatorModal] = useState< + boolean + >(false); + const [showTxModal, setShowTxModal] = useState(false); + const [transactionStatus, setTransactionStatus] = useState( + 'not_started' + ); + const [txHash, setTxHash] = useState(''); + + const confirmConsolidate = () => { + setShowSelectValidatorModal(true); + setSelectedValidator(null); + }; + + const closeSelectValidatorModal = () => { + setShowSelectValidatorModal(false); + setSelectedValidator(null); + }; + + const createConsolidationTransaction = async () => { + if (!account || !selectedValidator) { + return; + } + + setTransactionStatus('waiting_user_confirmation'); + setShowSelectValidatorModal(false); + setShowTxModal(true); + + try { + const walletProvider = await (connector as AbstractConnector).getProvider(); + const web3 = new Web3(walletProvider); + + const params = await generateCompoundParams( + web3, + account, + validator.pubkey, + selectedValidator.pubkey + ); + return web3.eth + .sendTransaction(params) + .on('transactionHash', (hash: string): void => { + setTransactionStatus('confirm_on_chain'); + setTxHash(hash); + }) + .on('confirmation', (): any => { + setTransactionStatus('success'); + }) + .on('error', () => { + setTransactionStatus('error'); + }); + } catch (e) { + console.log(e); + } + }; + + return ( + <> + {showSelectValidatorModal && ( + closeSelectValidatorModal()}> + + + + + ({ + value: v.pubkey, + label: v.validatorindex.toString(), + }))} + value={selectedValidator?.pubkey || ''} + onChange={(value: string) => { + setSelectedValidator( + validators.find(v => v.pubkey === value) || null + ); + }} + /> + + + closeSelectValidatorModal()} + /> + createConsolidationTransaction()} + /> + + + )} + + {showTxModal && selectedValidator && ( + + } + txHash={txHash} + transactionStatus={transactionStatus} + onClose={() => setShowTxModal(false)} + /> + )} + + confirmConsolidate()} /> + > + ); +}; + +export default Consolidate; diff --git a/src/pages/Actions/components/ForceExit.tsx b/src/pages/Actions/components/ForceExit.tsx new file mode 100644 index 00000000..77c8d8a6 --- /dev/null +++ b/src/pages/Actions/components/ForceExit.tsx @@ -0,0 +1,139 @@ +import { AbstractConnector } from '@web3-react/abstract-connector'; +import { useWeb3React } from '@web3-react/core'; +import { Box, Button, Heading, Layer } from 'grommet'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import Web3 from 'web3'; +import { + TransactionStatus, + TransactionStatusModal, +} from '../../../components/TransactionStatusModal'; +import { Validator } from '../types'; +import { Text } from '../../../components/Text'; +import { generateWithdrawalParams } from '../ActionUtils'; + +interface Props { + validator: Validator; +} + +const ForceExit: React.FC = ({ validator }) => { + const { connector, account } = useWeb3React(); + + const [showConfirmationModal, setShowConfirmationModal] = useState( + false + ); + const [stepTwo, setStepTwo] = useState(false); + + const [showTxModal, setShowTxModal] = useState(false); + const [transactionStatus, setTransactionStatus] = useState( + 'not_started' + ); + const [txHash, setTxHash] = useState(''); + + const confirmForceExit = () => { + setStepTwo(false); + setShowConfirmationModal(true); + }; + + const closeConfirmationModal = () => { + setShowConfirmationModal(false); + setStepTwo(false); + }; + + const createExitTransaction = async () => { + if (!account) { + return; + } + + setTransactionStatus('waiting_user_confirmation'); + setShowConfirmationModal(false); + setStepTwo(false); + setShowTxModal(true); + + const walletProvider = await (connector as AbstractConnector).getProvider(); + const web3 = new Web3(walletProvider); + + // Force exits have withdrawal amount of 0 + const transactionParams = await generateWithdrawalParams( + web3, + account, + validator.pubkey, + 0 + ); + + web3.eth + .sendTransaction(transactionParams) + .on('transactionHash', (hash: string): void => { + setTransactionStatus('confirm_on_chain'); + setTxHash(hash); + }) + .on('confirmation', (): any => { + setTransactionStatus('success'); + }) + .on('error', () => { + setTransactionStatus('error'); + }); + }; + + return ( + <> + {showConfirmationModal && ( + closeConfirmationModal()}> + + + + + + + + + + + closeConfirmationModal()} /> + {stepTwo ? ( + createExitTransaction()} + color="dark-3" + /> + ) : ( + setStepTwo(true)} + color="dark-3" + /> + )} + + + )} + + {showTxModal && ( + + } + txHash={txHash} + transactionStatus={transactionStatus} + onClose={() => setShowTxModal(false)} + /> + )} + + confirmForceExit()} /> + > + ); +}; + +export default ForceExit; diff --git a/src/pages/Actions/components/PartialWithdraw.tsx b/src/pages/Actions/components/PartialWithdraw.tsx new file mode 100644 index 00000000..e72acaac --- /dev/null +++ b/src/pages/Actions/components/PartialWithdraw.tsx @@ -0,0 +1,156 @@ +import { AbstractConnector } from '@web3-react/abstract-connector'; +import { useWeb3React } from '@web3-react/core'; +import { Box, Button, Heading, Layer } from 'grommet'; +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import Web3 from 'web3'; +import { Validator } from '../types'; +import { NumberInput } from '../../../components/NumberInput'; +import { Text } from '../../../components/Text'; +import { + TransactionStatus, + TransactionStatusModal, +} from '../../../components/TransactionStatusModal'; +import { MIN_VALIDATOR_BALANCE, TICKER_NAME } from '../../../utils/envVars'; + +interface Props { + validator: Validator; +} + +const PartialWithdraw: React.FC = ({ validator }) => { + const { connector, account } = useWeb3React(); + + const [amount, setAmount] = useState(0); + const [maxAmount, setMaxAmount] = useState(0); + const [showInputModal, setShowInputModal] = useState(false); + const [showTxModal, setShowTxModal] = useState(false); + const [transactionStatus, setTransactionStatus] = useState( + 'not_started' + ); + const [txHash, setTxHash] = useState(''); + + useEffect(() => { + setMaxAmount( + validator ? Math.max(0, validator.coinBalance - MIN_VALIDATOR_BALANCE) : 0 + ); + }, [validator]); + + const prepareInputModal = () => { + setAmount(0); + setShowInputModal(true); + }; + + const createWithdrawTransaction = async () => { + if (!amount) { + return; + } + + setTransactionStatus('waiting_user_confirmation'); + setShowTxModal(true); + + const walletProvider: any = await (connector as AbstractConnector).getProvider(); + const web3: any = new Web3(walletProvider); + + // TODO: Replace with contract call + const transactionParams = { + to: '0x40EDC53b0559D3A360DBe2DdB58f71A8833416E1', + from: account, + value: web3.utils.toWei(amount, 'ether'), + }; + + web3.eth + .sendTransaction(transactionParams) + .on('transactionHash', (hash: string): void => { + setTransactionStatus('confirm_on_chain'); + setTxHash(hash); + }) + .on('confirmation', (): any => { + setTransactionStatus('success'); + }) + .on('error', () => { + setTransactionStatus('error'); + }); + }; + + return ( + <> + {showInputModal && ( + setShowInputModal(false)}> + + + + + + + {validator.coinBalance <= MIN_VALIDATOR_BALANCE ? ( + + ) : ( + <> + + + + > + )} + + + + setShowInputModal(false)} /> + + createWithdrawTransaction()} + color="dark-3" + /> + + + )} + + {showTxModal && ( + + } + txHash={txHash} + transactionStatus={transactionStatus} + onClose={() => setShowTxModal(false)} + /> + )} + + prepareInputModal()}>Partial Withdraw + > + ); +}; + +export default PartialWithdraw; diff --git a/src/pages/Actions/components/UpgradeCompounding.tsx b/src/pages/Actions/components/UpgradeCompounding.tsx new file mode 100644 index 00000000..5d96f973 --- /dev/null +++ b/src/pages/Actions/components/UpgradeCompounding.tsx @@ -0,0 +1,85 @@ +import { AbstractConnector } from '@web3-react/abstract-connector'; +import { useWeb3React } from '@web3-react/core'; +import { Button } from 'grommet'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import Web3 from 'web3'; +import { Validator } from '../types'; +import { + TransactionStatus, + TransactionStatusModal, +} from '../../../components/TransactionStatusModal'; +import { generateCompoundParams } from '../ActionUtils'; + +interface Props { + validator: Validator; +} + +const UpgradeCompounding: React.FC = ({ validator }) => { + const { connector, account } = useWeb3React(); + + const [showTxModal, setShowTxModal] = useState(false); + const [transactionStatus, setTransactionStatus] = useState( + 'not_started' + ); + const [txHash, setTxHash] = useState(''); + + const createUpgradeMessage = async () => { + if (!account) { + return; + } + + setTransactionStatus('waiting_user_confirmation'); + setShowTxModal(true); + + try { + const walletProvider = await (connector as AbstractConnector).getProvider(); + const web3 = new Web3(walletProvider); + + const params = await generateCompoundParams( + web3, + account, + validator.pubkey, + validator.pubkey + ); + return web3.eth + .sendTransaction(params) + .on('transactionHash', (hash: string): void => { + setTransactionStatus('confirm_on_chain'); + setTxHash(hash); + }) + .on('confirmation', (): any => { + setTransactionStatus('success'); + }) + .on('error', () => { + setTransactionStatus('error'); + }); + } catch (e) { + console.log(e); + } + }; + + return ( + <> + {showTxModal && ( + + } + txHash={txHash} + transactionStatus={transactionStatus} + onClose={() => setShowTxModal(false)} + /> + )} + + createUpgradeMessage()}> + Upgrade to "Compounding" + + > + ); +}; + +export default UpgradeCompounding; diff --git a/src/pages/Actions/components/ValidatorActions.tsx b/src/pages/Actions/components/ValidatorActions.tsx new file mode 100644 index 00000000..f17128ec --- /dev/null +++ b/src/pages/Actions/components/ValidatorActions.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import styled from 'styled-components'; + +import { Text } from '../../../components/Text'; +import { Alert } from '../../../components/Alert'; +import { Link } from '../../../components/Link'; + +import { Validator } from '../types'; +import Consolidate from './Consolidate'; +import ForceExit from './ForceExit'; +import PartialWithdraw from './PartialWithdraw'; +import UpgradeCompounding from './UpgradeCompounding'; + +import { + BLS_CREDENTIALS, + COMPOUNDING_CREDENTIALS, + EXECUTION_CREDENTIALS, +} from '../../../utils/envVars'; + +import { routesEnum } from '../../../Routes'; + +interface Props { + validator: Validator; + validators: Validator[]; +} + +const InlineLink = styled(Link)` + display: inline; +`; + +const ValidatorDetails = styled.div` + background-color: white; + padding: 1rem; + margin: 1rem; + border-radius: 4px; + + span { + margin-bottom: 5px; + } +`; + +const Actions = styled.div` + margin-top: 10px; +`; + +const ValidatorActions: React.FC = ({ validator, validators }) => { + const [sharedValidators, setSharedValidators] = useState([]); + + useEffect(() => { + if (validators && validators) { + setSharedValidators( + validators.filter( + v => v.withdrawalcredentials === validator.withdrawalcredentials + ) + ); + } else { + setSharedValidators([]); + } + }, [validator, validators]); + + return ( + <> + + + + + + + + + + + + + + + + + + + {validator.withdrawalcredentials.substring(0, 4) === BLS_CREDENTIALS ? ( + + + + + + + + + + ), + }} + /> + + + ) : ( + + + {validator.withdrawalcredentials.substring(0, 4) === + EXECUTION_CREDENTIALS && ( + + )} + {validator.withdrawalcredentials.substring(0, 4) === + COMPOUNDING_CREDENTIALS && ( + + )} + {sharedValidators.length > 0 && ( + + )} + + + + )} + > + ); +}; + +export default ValidatorActions; diff --git a/src/pages/Actions/index.tsx b/src/pages/Actions/index.tsx new file mode 100644 index 00000000..2be0e4f8 --- /dev/null +++ b/src/pages/Actions/index.tsx @@ -0,0 +1,293 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import styled from 'styled-components'; +import { Web3Provider } from '@ethersproject/providers'; +import { useWeb3React } from '@web3-react/core'; +import BigNumber from 'bignumber.js'; + +import { + BeaconChainValidator, + BeaconChainValidatorResponse, +} from '../TopUp/types'; +import { Props, Validator } from './types'; + +import ValidatorActions from './components/ValidatorActions'; + +import { Button } from '../../components/Button'; +import { PageTemplate } from '../../components/PageTemplate'; +import { Alert } from '../../components/Alert'; +import Select from '../../components/Select'; +import Spinner from '../../components/Spinner'; +import { Text } from '../../components/Text'; + +import { web3ReactInterface } from '../ConnectWallet'; +import { AllowedELNetworks, NetworkChainId } from '../ConnectWallet/web3Utils'; +import WalletConnectModal from '../TopUp/components/WalletConnectModal'; + +import { + BEACONCHAIN_URL, + ETHER_TO_GWEI, + TICKER_NAME, +} from '../../utils/envVars'; + +const Container = styled.div` + background-color: white; + padding: 1rem; + margin: 1rem; + border-radius: 4px; +`; + +const FakeLink = styled.span` + color: blue; + text-decoration: underline; + cursor: pointer; + display: inline; +`; + +const ButtonLink = styled(FakeLink)` + text-decoration: none; + width: fit-content; + button { + padding: 16px 32px; + } +`; + +const ButtonContainer = styled.div` + display: flex; + justify-content: center; + @media screen and (max-width: 518px) { + span, + button { + width: 100%; + } + } +`; + +const _ActionsPage: React.FC = () => { + const { + account, + active, + chainId, + deactivate, + }: web3ReactInterface = useWeb3React(); + const { formatMessage } = useIntl(); + + const [loading, setLoading] = React.useState(false); + const [selectedValidator, setSelectedValidator] = useState( + null + ); + const [validatorLoadError, setValidatorLoadError] = React.useState( + false + ); + const [validators, setValidators] = useState([]); + + const handleConnect = useCallback((): void => { + setLoading(true); + deactivate(); + }, [setLoading, deactivate]); + + // an effect that fetches validators from beaconchain when the user connects or changes their wallet + useEffect(() => { + const fetchValidatorsForUserAddress = async () => { + setLoading(true); + + // beaconchain API requires two fetches - one that gets the public keys for an Ethereum address, and one that + // queries by the validators public keys + fetch( + `${BEACONCHAIN_URL}/api/v1/validator/withdrawalCredentials/${account}?limit=100` + ) + .then(r => r.json()) + .then( + ({ + data, + }: { + data: BeaconChainValidatorResponse[] | BeaconChainValidatorResponse; + }) => { + const response = Array.isArray(data) ? data : [data]; + // no validators for that user's wallet address + if (response.length === 0) { + setValidators([]); + setLoading(false); + return; + } + + if (response.length === 0) { + setValidators([]); + setLoading(false); + } else { + // query by public keys + const pubKeysCommaDelineated = `${response + .slice(0, 50) + .map(validator => validator.publickey) + .join(',')}`; + + fetch( + `${BEACONCHAIN_URL}/api/v1/validator/${pubKeysCommaDelineated}` + ) + .then(r => r.json()) + .then( + ({ + data, + }: { + data: BeaconChainValidator | BeaconChainValidator[]; + }) => { + const validatorsData = Array.isArray(data) ? data : [data]; + setValidators( + validatorsData.map(v => { + const coinBalance = new BigNumber(v.balance) + .div(ETHER_TO_GWEI) + .toNumber(); + + return { + ...v, + balanceDisplay: `${coinBalance}${TICKER_NAME}`, + coinBalance, + }; + }) + ); + setLoading(false); + } + ) + .catch(error => { + console.log(error); + setLoading(false); + setValidatorLoadError(true); + }); + } + } + ) + .catch(error => { + console.log(error); + setLoading(false); + setValidatorLoadError(true); + }); + }; + + const network = NetworkChainId[chainId as number]; + + const isValidNetwork = AllowedELNetworks.includes(network); + + if (active && account && isValidNetwork) { + fetchValidatorsForUserAddress(); + } + }, [account, active, chainId]); + + const actionsPageContent = useMemo(() => { + if (loading) { + return ; + } + + if (!active) { + return ( + + + + + + + + + + + + + ); + } + + if (validatorLoadError) { + return ( + + + + ); + } + + if (active && !loading && validators.length === 0) { + return ( + + {' '} + + + + + ); + } + + return ( + <> + + + + + ({ + value: v.pubkey, + searchContext: `${v.validatorindex.toString()}:${v.pubkey}`, + label: ( + + + {v.validatorindex.toString()} + + + {(v.balance / ETHER_TO_GWEI).toFixed(9)} {TICKER_NAME} + + + ), + }))} + value={selectedValidator?.pubkey || ''} + onChange={(value: string) => { + const validator = validators.find(v => v.pubkey === value); + setSelectedValidator(validator || null); + }} + /> + + + {selectedValidator && ( + + )} + > + ); + }, [loading, active, selectedValidator, validators]); + + return ( + <> + setLoading(false)} + /> + + {actionsPageContent} + + > + ); +}; + +export const ActionsPage = _ActionsPage; diff --git a/src/pages/Actions/types.ts b/src/pages/Actions/types.ts new file mode 100644 index 00000000..3662df4d --- /dev/null +++ b/src/pages/Actions/types.ts @@ -0,0 +1,22 @@ +export interface OwnProps {} +export interface StateProps {} +export interface DispatchProps {} +export type Props = StateProps & DispatchProps & OwnProps; + +export interface Validator { + activationeligibilityepoch: number; + activationepoch: number; + balance: number; + balanceDisplay: string; + coinBalance: number; + effectivebalance: number; + exitepoch: number; + lastattestationslot: number; + name: string; + pubkey: string; + slashed: boolean; + status: string; + validatorindex: number; + withdrawableepoch: number; + withdrawalcredentials: string; +} diff --git a/src/pages/GenerateKeys/index.tsx b/src/pages/GenerateKeys/index.tsx index 9be433f0..290a9909 100644 --- a/src/pages/GenerateKeys/index.tsx +++ b/src/pages/GenerateKeys/index.tsx @@ -9,7 +9,7 @@ import { FormattedMessage, useIntl } from 'react-intl'; import { toChecksumAddress } from 'ethereumjs-util'; // Components import { Instructions } from './Instructions'; -import { NumberInput } from './NumberInput'; +import { NumberInput } from '../../components/NumberInput'; import { OperatingSystemButtons } from './OperatingSystemButtons'; import { WorkflowPageTemplate } from '../../components/WorkflowPage/WorkflowPageTemplate'; import { Alert } from '../../components/Alert'; diff --git a/src/pages/Summary/index.tsx b/src/pages/Summary/index.tsx index 4c907201..8b6fdfea 100644 --- a/src/pages/Summary/index.tsx +++ b/src/pages/Summary/index.tsx @@ -167,11 +167,11 @@ const _SummaryPage = ({ setLosePhrase(e.target.checked)} checked={losePhrase} - label={( + label={ - )} + } /> )} @@ -179,21 +179,21 @@ const _SummaryPage = ({ setSoftwareRisk(e.target.checked)} checked={softwareRisk} - label={( + label={ - )} + } /> setNonReverse(e.target.checked)} checked={nonReverse} - label={( + label={ - )} + } /> setNoPhish(e.target.checked)} checked={noPhish} - label={( + label={ - )} + } /> diff --git a/src/pages/TopUp/components/TopUpInput.tsx b/src/pages/TopUp/components/TopUpInput.tsx deleted file mode 100644 index a1fe5999..00000000 --- a/src/pages/TopUp/components/TopUpInput.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { FormDown, FormUp } from 'grommet-icons'; - -const StyledButton = styled.button` - height: 25px; - width: 50px; - cursor: pointer; - border: 1px solid #ddd; - :focus { - outline: none; - } - :hover { - background-color: ${(p: any) => p.theme.gray.lightest}; - } -`; -const ButtonContainer = styled.div` - display: flex; - flex-direction: column; - button:nth-child(1) { - border-radius: ${(p: any) => `0 ${p.theme.borderRadius} 0 0`}; - } - button:nth-child(2) { - border-radius: ${(p: any) => `0 0 ${p.theme.borderRadius} 0`}; - } -`; -const StyledInput = styled.input` - height: 50px; - width: 100%; - font-size: 18px; - line-height: 24px; - color: #444444; - padding-inline-start: 10px; - box-sizing: border-box; - background-color: ${(p: any) => p.theme.gray.lightest}; - border-radius: ${(p: any) => - `${p.theme.borderRadius} 0 0 ${p.theme.borderRadius}`}; - -webkit-appearance: textfield; - -moz-appearance: textfield; - appearance: textfield; - ::-webkit-inner-spin-button, - ::-webkit-outer-spin-button { - -webkit-appearance: none; - } - border: 1px solid #ddd; - border-inline-end: none; - display: inline-flex; - :focus { - outline: none; - } -`; - -interface Props { - value: number | string; - setValue: (e: number) => void; - maxValue: number; -} - -export const TopupInput = ({ - value, - setValue, -}: // maxValue, -Props): JSX.Element => { - const handleManualInput = (e: any) => { - const val = e.target.value; - setValue(val); - }; - - const decrement = () => { - if (value > 0) { - setValue(Math.max(0, +value - 1)); - } - }; - - const increment = () => { - // setValue(Math.min(+value + 1, maxValue)); - setValue(+value + 1); - }; - - return ( - - - - - - - - - - - - ); -}; diff --git a/src/pages/TopUp/components/TopupPage.tsx b/src/pages/TopUp/components/TopupPage.tsx index 9e587b38..f921b9a1 100644 --- a/src/pages/TopUp/components/TopupPage.tsx +++ b/src/pages/TopUp/components/TopupPage.tsx @@ -12,17 +12,20 @@ import { Alert as AlertIcon } from 'grommet-icons'; import ReactTooltip from 'react-tooltip'; import styled from 'styled-components'; import { contractAbi } from '../../../contractAbi'; -import { BeaconChainValidator, TransactionStatus } from '../types'; +import { BeaconChainValidator } from '../types'; import { bufferHex } from '../../../utils/SSZ'; import { buf2hex } from '../../../utils/buf2hex'; import { Text } from '../../../components/Text'; import { Button } from '../../../components/Button'; import { Paper } from '../../../components/Paper'; import { Heading } from '../../../components/Heading'; -import { TopupInput } from './TopUpInput'; +import { NumberInput } from '../../../components/NumberInput'; import shortenAddress from '../../../utils/shortenAddress'; import { Alert } from '../../../components/Alert'; -import TopUpTransactionModal from './TopUpTransactionModal'; +import { + TransactionStatusModal, + TransactionStatus, +} from '../../../components/TransactionStatusModal'; import { fortmaticTxRejected, isLedgerTimeoutError, @@ -274,7 +277,8 @@ const TopupPage: React.FC = ({ validator }) => { {showTxModal && ( - setShowTxModal(false)} @@ -328,7 +332,8 @@ const TopupPage: React.FC = ({ validator }) => { - = () => { if (data.length === 0) { setShowDepositVerificationWarning(true); } - setValidators(data); + // A single validator will not result in an array + setValidators( + response.length === 1 + ? (([data] as unknown) as BeaconChainValidator[]) + : data + ); setLoading(false); }) .catch(error => { diff --git a/src/pages/TopUp/types.ts b/src/pages/TopUp/types.ts index 6cc7ba70..0f17d64a 100644 --- a/src/pages/TopUp/types.ts +++ b/src/pages/TopUp/types.ts @@ -51,13 +51,3 @@ export interface DepositData { amount: Number; signature: ByteVector; } - -export type TransactionStatus = - | 'not_started' - | 'waiting_user_confirmation' - | 'user_rejected' - | 'confirm_on_chain' - | 'error' - | 'success'; - -export type stepStatus = 'loading' | 'staged' | 'complete' | 'error'; diff --git a/src/pages/index.ts b/src/pages/index.ts index 16aefd56..d099d396 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,3 +1,4 @@ +export * from './Actions'; export * from './Congratulations'; export * from './ConnectWallet'; export * from './GenerateKeys'; diff --git a/src/utils/envVars.ts b/src/utils/envVars.ts index 32493400..09a72c36 100644 --- a/src/utils/envVars.ts +++ b/src/utils/envVars.ts @@ -74,5 +74,10 @@ export const MIN_DEPOSIT_AMOUNT = 1 * ETHER_TO_GWEI; export const DOMAIN_DEPOSIT = Buffer.from('03000000', 'hex'); export const EMPTY_ROOT = Buffer.from('0000000000000000000000000000000000000000000000000000000000000000', 'hex'); +export const BLS_CREDENTIALS = "0x00"; +export const EXECUTION_CREDENTIALS = "0x01"; +export const COMPOUNDING_CREDENTIALS = "0x02"; +export const MIN_VALIDATOR_BALANCE = 32; + // Boolean to translate CLI command flags, or keep in English export const TRANSLATE_CLI_FLAGS = process.env.REACT_APP_TRANSLATE_CLI_FLAGS === 'true';
+ {v.validatorindex.toString()} +
+ {(v.balance / ETHER_TO_GWEI).toFixed(9)} {TICKER_NAME} +