diff --git a/src/config/urls.js b/src/config/urls.js index 1c9ea00a30..aab3e46d9a 100644 --- a/src/config/urls.js +++ b/src/config/urls.js @@ -209,6 +209,8 @@ export const urls = { website: "https://elrond.com", }, solana: { + staking: "https://support.ledger.com/hc/en-us/articles/4731749170461?docs=true", recipient_info: "https://support.ledger.com", + ledgerByFigmentTC: "https://drive.google.com/file/d/1vlIh2gTwtbMon8_bzFQGjCLhTUqS5uQc", }, }; diff --git a/src/renderer/components/Delegation/ValidatorRow.js b/src/renderer/components/Delegation/ValidatorRow.js index 60dfcee457..2e9fffb0c2 100644 --- a/src/renderer/components/Delegation/ValidatorRow.js +++ b/src/renderer/components/Delegation/ValidatorRow.js @@ -171,7 +171,7 @@ const Row: ThemedComponent<{ active: boolean, disabled: boolean }> = styled(Box) : ""} `; -type ValidatorRowProps = { +export type ValidatorRowProps = { validator: { address: string }, icon: React$Node, title: React$Node, @@ -188,6 +188,7 @@ type ValidatorRowProps = { unit: Unit, onMax?: () => void, shouldRenderMax?: boolean, + className?: string, }; const ValidatorRow = ({ @@ -207,6 +208,7 @@ const ValidatorRow = ({ unit, onMax, shouldRenderMax, + className, }: ValidatorRowProps) => { const inputRef = useRef(); const onTitleClick = useCallback( @@ -269,7 +271,13 @@ const ValidatorRow = ({ ); return ( - + {icon} diff --git a/src/renderer/families/solana/AccountBalanceSummaryFooter.js b/src/renderer/families/solana/AccountBalanceSummaryFooter.js new file mode 100644 index 0000000000..c589aef14b --- /dev/null +++ b/src/renderer/families/solana/AccountBalanceSummaryFooter.js @@ -0,0 +1,144 @@ +// @flow + +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import React from "react"; +import { Trans } from "react-i18next"; +import { useSelector } from "react-redux"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box/Box"; +import Discreet, { useDiscreetMode } from "~/renderer/components/Discreet"; +import Text from "~/renderer/components/Text"; +import ToolTip from "~/renderer/components/Tooltip"; +import InfoCircle from "~/renderer/icons/InfoCircle"; +import { localeSelector } from "~/renderer/reducers/settings"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import type { Account } from "@ledgerhq/live-common/lib/types"; +import { BigNumber } from "bignumber.js"; + +const Wrapper: ThemedComponent<*> = styled(Box).attrs(() => ({ + horizontal: true, + mt: 4, + p: 5, + pb: 0, +}))` + border-top: 1px solid ${p => p.theme.colors.palette.text.shade10}; +`; + +const BalanceDetail = styled(Box).attrs(() => ({ + flex: "0.25 0 auto", + vertical: true, + alignItems: "start", +}))` + &:nth-child(n + 3) { + flex: 0.75; + } +`; + +const TitleWrapper = styled(Box).attrs(() => ({ horizontal: true, alignItems: "center", mb: 1 }))``; + +const Title = styled(Text).attrs(() => ({ + fontSize: 4, + ff: "Inter|Medium", + color: "palette.text.shade60", +}))` + line-height: ${p => p.theme.space[4]}px; + margin-right: ${p => p.theme.space[1]}px; +`; + +const AmountValue = styled(Text).attrs(() => ({ + fontSize: 6, + ff: "Inter|SemiBold", + color: "palette.text.shade100", +}))``; + +type Props = { + account: Account, + countervalue: any, +}; + +const AccountBalanceSummaryFooter = ({ account, countervalue }: Props) => { + const discreet = useDiscreetMode(); + const locale = useSelector(localeSelector); + if (!account.solanaResources) return null; + + const { spendableBalance: _spendableBalance, solanaResources } = account; + + const { stakes } = solanaResources; + + const _delegatedBalance = new BigNumber( + stakes.reduce((sum, s) => sum + (s.delegation?.stake ?? 0), 0), + ); + + const _delegatedWithdrawableBalance = new BigNumber( + stakes.reduce((sum, s) => sum + s.withdrawable, 0), + ); + + const unit = getAccountUnit(account); + + const formatConfig = { + disableRounding: true, + alwaysShowSign: false, + showCode: true, + discreet, + locale, + }; + + const spendableBalance = formatCurrencyUnit(unit, _spendableBalance, formatConfig); + + const delegatedBalance = formatCurrencyUnit(unit, _delegatedBalance, formatConfig); + + const delegatedWithdrawableBalance = formatCurrencyUnit( + unit, + _delegatedWithdrawableBalance, + formatConfig, + ); + + return ( + <Wrapper> + <BalanceDetail> + <ToolTip content={<Trans i18nKey="account.availableBalanceTooltip" />}> + <TitleWrapper> + <Title> + <Trans i18nKey="account.availableBalance" /> + + + + + + {spendableBalance} + + + + }> + + + <Trans i18nKey="account.delegatedAssets" /> + + + + + + {delegatedBalance} + + + {_delegatedWithdrawableBalance.gt(0) && ( + + }> + + + <Trans i18nKey="solana.delegation.withdrawableTitle" /> + + + + + + {delegatedWithdrawableBalance} + + + )} + + ); +}; + +export default AccountBalanceSummaryFooter; diff --git a/src/renderer/families/solana/AccountBodyHeader.js b/src/renderer/families/solana/AccountBodyHeader.js new file mode 100644 index 0000000000..ccaac0064c --- /dev/null +++ b/src/renderer/families/solana/AccountBodyHeader.js @@ -0,0 +1,5 @@ +// @flow + +import Delegation from "./Delegation"; + +export default Delegation; diff --git a/src/renderer/families/solana/AccountHeaderManageActions.js b/src/renderer/families/solana/AccountHeaderManageActions.js new file mode 100644 index 0000000000..b38f1d8787 --- /dev/null +++ b/src/renderer/families/solana/AccountHeaderManageActions.js @@ -0,0 +1,45 @@ +// @flow +import { getMainAccount } from "@ledgerhq/live-common/lib/account"; +import type { Account, AccountLike } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; +import { openModal } from "~/renderer/actions/modals"; +import IconChartLine from "~/renderer/icons/ChartLine"; + +type Props = { + account: AccountLike, + parentAccount: ?Account, +}; + +const AccountHeaderActions = ({ account, parentAccount }: Props) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const onClick = useCallback(() => { + dispatch( + openModal("MODAL_SOLANA_REWARDS_INFO", { + account, + }), + ); + }, [dispatch, account]); + + const mainAccount = getMainAccount(account, parentAccount); + const { solanaResources } = mainAccount; + + if (!solanaResources || solanaResources.stakes.length > 0) { + return null; + } + + return [ + { + key: "solana", + onClick: onClick, + icon: IconChartLine, + label: t("delegation.title"), + }, + ]; +}; + +export default AccountHeaderActions; diff --git a/src/renderer/families/solana/Delegation/Header.js b/src/renderer/families/solana/Delegation/Header.js new file mode 100644 index 0000000000..239273cbd4 --- /dev/null +++ b/src/renderer/families/solana/Delegation/Header.js @@ -0,0 +1,65 @@ +// @flow + +import React from "react"; +import { Trans } from "react-i18next"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box/Box"; +import { HeaderWrapper } from "~/renderer/components/TableContainer"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; + +export const TableLine: ThemedComponent<{}> = styled(Box).attrs(() => ({ + ff: "Inter|SemiBold", + color: "palette.text.shade60", + horizontal: true, + alignItems: "center", + justifyContent: "flex-start", + fontSize: 3, + flex: 1.125, + pr: 2, +}))` + box-sizing: border-box; + &:last-child { + justify-content: flex-end; + flex: 0.5; + text-align: right; + white-space: nowrap; + } +`; + +export const Header = () => ( + + + + + + + + + + + + + + + + + + +); + +export const UnbondingHeader = () => ( + + + + + + + + + + + + + + +); diff --git a/src/renderer/families/solana/Delegation/Row.js b/src/renderer/families/solana/Delegation/Row.js new file mode 100644 index 0000000000..60b4a3748b --- /dev/null +++ b/src/renderer/families/solana/Delegation/Row.js @@ -0,0 +1,199 @@ +// @flow + +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import { + stakeActions as solanaStakeActions, + stakeActivePercent, +} from "@ledgerhq/live-common/lib/families/solana/logic"; +import type { SolanaStakeWithMeta } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { Account } from "@ledgerhq/live-common/lib/types"; +import { BigNumber } from "bignumber.js"; +import React, { useCallback } from "react"; +import { Trans } from "react-i18next"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box/Box"; +import DropDown, { DropDownItem } from "~/renderer/components/DropDownSelector"; +import FirstLetterIcon from "~/renderer/components/FirstLetterIcon"; +import Image from "~/renderer/components/Image"; +import Text from "~/renderer/components/Text"; +import ToolTip from "~/renderer/components/Tooltip"; +import CheckCircle from "~/renderer/icons/CheckCircle"; +import ChevronRight from "~/renderer/icons/ChevronRight"; +import ExclamationCircleThin from "~/renderer/icons/ExclamationCircleThin"; +import Loader from "~/renderer/icons/Loader"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import { TableLine } from "./Header"; + +const Wrapper: ThemedComponent<*> = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 16px 20px; +`; + +const Column: ThemedComponent<{ clickable?: boolean }> = styled(TableLine).attrs(p => ({ + ff: "Inter|SemiBold", + color: p.strong ? "palette.text.shade100" : "palette.text.shade80", + fontSize: 3, +}))` + cursor: ${p => (p.clickable ? "pointer" : "cursor")}; + ${p => + p.clickable + ? ` + &:hover { + color: ${p.theme.colors.palette.primary.main}; + } + ` + : ``} +`; + +const Ellipsis: ThemedComponent<{}> = styled.div` + flex: 1; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Divider: ThemedComponent<*> = styled.div` + width: 100%; + height: 1px; + margin-bottom: ${p => p.theme.space[1]}px; + background-color: ${p => p.theme.colors.palette.divider}; +`; + +const ManageDropDownItem = ({ + item, + isActive, +}: { + item: { key: string, label: string, disabled: boolean, tooltip: React$Node }, + isActive: boolean, +}) => { + return ( + <> + + + + {item.label} + + + + + ); +}; + +type Props = { + account: Account, + stakeWithMeta: SolanaStakeWithMeta, + onManageAction: (stakeWithMeta: SolanaStakeWithMeta, action: string) => void, + onExternalLink: (address: string) => void, +}; + +export function Row({ account, stakeWithMeta, onManageAction, onExternalLink }: Props) { + const onSelect = useCallback( + action => { + onManageAction(stakeWithMeta, action.key); + }, + [onManageAction], + ); + + const { stake, meta } = stakeWithMeta; + + const stakeActions = solanaStakeActions(stake).map(toStakeDropDownItem); + + const validatorName = meta.validator?.name ?? stake.delegation?.voteAccAddr ?? "-"; + + const onExternalLinkClick = () => onExternalLink(stakeWithMeta); + + const formatAmount = (amount: number) => { + const unit = getAccountUnit(account); + return formatCurrencyUnit(unit, new BigNumber(amount), { + disableRounding: true, + alwaysShowSign: false, + showCode: true, + }); + }; + + return ( + + + + {meta.validator?.img !== undefined && ( + + )} + {meta.validator?.img === undefined && } + + {validatorName} + + + {stake.activation.state === "active" && ( + + }> + + + + )} + {stake.activation.state === "inactive" && ( + + }> + + + + )} + {(stake.activation.state === "activating" || stake.activation.state === "deactivating") && ( + + }> + + + + )} + {stake.activation.state} + + {formatAmount(stake.delegation?.stake ?? 0)} + {stake.delegation === undefined ? 0 : stakeActivePercent(stake).toFixed(2)} % + {formatAmount(stake.withdrawable)} + + + {({ isOpen, value }) => { + return ( + + +
+ +
+
+ ); + }} +
+
+
+ ); +} + +function toStakeDropDownItem(stakeAction: string) { + switch (stakeAction) { + case "activate": + return { + key: "MODAL_SOLANA_DELEGATION_ACTIVATE", + label: , + }; + case "reactivate": + return { + key: "MODAL_SOLANA_DELEGATION_REACTIVATE", + label: , + }; + case "deactivate": + return { + key: "MODAL_SOLANA_DELEGATION_DEACTIVATE", + label: , + }; + case "withdraw": + return { + key: "MODAL_SOLANA_DELEGATION_WITHDRAW", + label: , + }; + default: + throw new Error(`unsupported stake action: ${stakeAction}`); + } +} diff --git a/src/renderer/families/solana/Delegation/index.js b/src/renderer/families/solana/Delegation/index.js new file mode 100644 index 0000000000..fda77a22ff --- /dev/null +++ b/src/renderer/families/solana/Delegation/index.js @@ -0,0 +1,165 @@ +// @flow +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { getAddressExplorer, getDefaultExplorerView } from "@ledgerhq/live-common/lib/explorers"; +import { useSolanaStakesWithMeta } from "@ledgerhq/live-common/lib/families/solana/react"; +import type { SolanaStakeWithMeta } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { Account } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React, { useCallback } from "react"; +import { Trans } from "react-i18next"; +import { useDispatch } from "react-redux"; +import styled from "styled-components"; +import { urls } from "~/config/urls"; +import { openModal } from "~/renderer/actions/modals"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import LinkWithExternalIcon from "~/renderer/components/LinkWithExternalIcon"; +import TableContainer, { TableHeader } from "~/renderer/components/TableContainer"; +import Text from "~/renderer/components/Text"; +import IconChartLine from "~/renderer/icons/ChartLine"; +import DelegateIcon from "~/renderer/icons/Delegate"; +import { openURL } from "~/renderer/linking"; +import { Header } from "./Header"; +import { Row } from "./Row"; + +type Props = { + account: Account, +}; + +const Wrapper = styled(Box).attrs(() => ({ + p: 3, +}))` + border-radius: 4px; + justify-content: space-between; + align-items: center; +`; + +const Delegation = ({ account }: Props) => { + const { solanaResources } = account; + invariant(solanaResources, "solana account and resources expected"); + + const dispatch = useDispatch(); + + const stakesWithMeta = useSolanaStakesWithMeta(account.currency, solanaResources.stakes); + + const unit = getAccountUnit(account); + + const onEarnRewards = useCallback(() => { + dispatch( + openModal("MODAL_SOLANA_DELEGATE", { + account, + }), + ); + }, [account, dispatch]); + + const onDelegate = useCallback(() => { + dispatch( + openModal("MODAL_SOLANA_DELEGATE", { + account, + }), + ); + }, [account, dispatch]); + + const onRedirect = useCallback( + (stakeWithMeta: SolanaStakeWithMeta, modalName: string) => { + dispatch( + openModal(modalName, { + account, + stakeWithMeta, + }), + ); + }, + [account, dispatch], + ); + + const explorerView = getDefaultExplorerView(account.currency); + + const onExternalLink = useCallback( + ({ meta, stake }: SolanaStakeWithMeta) => { + const url = + meta.validator?.url ?? + (stake.delegation?.voteAccAddr && + explorerView && + getAddressExplorer(explorerView, stake.delegation.voteAccAddr)); + + if (url) { + openURL(url); + } + }, + [explorerView], + ); + + const hasStakes = stakesWithMeta.length > 0; + + return ( + <> + + }> + + + {hasStakes ? ( + <> +
+ {stakesWithMeta.map(stakeWithMeta => ( + + ))} + + ) : ( + + + + + + + } + onClick={() => openURL(urls.solana.staking)} + /> + + + + + + + )} + + + ); +}; + +const Delegations = ({ account }: Props) => { + if (!account.solanaResources) return null; + + return ; +}; + +export default Delegations; diff --git a/src/renderer/families/solana/DelegationActivateFlowModal/Body.js b/src/renderer/families/solana/DelegationActivateFlowModal/Body.js new file mode 100644 index 0000000000..eb28a22904 --- /dev/null +++ b/src/renderer/families/solana/DelegationActivateFlowModal/Body.js @@ -0,0 +1,208 @@ +// @flow +import { UserRefusedOnDevice } from "@ledgerhq/errors"; +import { addPendingOperation } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import useBridgeTransaction from "@ledgerhq/live-common/lib/bridge/useBridgeTransaction"; +import type { StakeWithMeta, Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { AccountBridge, Operation, Account } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React, { useCallback, useState } from "react"; +import { Trans, withTranslation } from "react-i18next"; +import type { TFunction } from "react-i18next"; +import { connect, useDispatch } from "react-redux"; +import { compose } from "redux"; +import { createStructuredSelector } from "reselect"; +import logger from "~/logger/logger"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { closeModal, openModal } from "~/renderer/actions/modals"; +import Track from "~/renderer/analytics/Track"; +import Stepper from "~/renderer/components/Stepper"; +import GenericStepConnectDevice from "~/renderer/modals/Send/steps/GenericStepConnectDevice"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import StepConfirmation, { StepConfirmationFooter } from "./steps/StepConfirmation"; +import StepValidator, { StepValidatorFooter } from "./steps/StepValidator"; +import type { St, StepId } from "./types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; + +type OwnProps = {| + stepId: StepId, + onClose: () => void, + onChangeStepId: StepId => void, + params: { + account: Account, + stakeWithMeta: StakeWithMeta, + parentAccount: ?Account, + }, + name: string, +|}; + +type StateProps = {| + t: TFunction, + device: ?Device, + accounts: Account[], + device: ?Device, + closeModal: string => void, + openModal: string => void, +|}; + +type Props = OwnProps & StateProps; + +const steps: Array = [ + { + id: "validator", + label: , + component: StepValidator, + noScroll: true, + footer: StepValidatorFooter, + }, + { + id: "connectDevice", + label: , + component: GenericStepConnectDevice, + }, + { + id: "confirmation", + label: , + component: StepConfirmation, + footer: StepConfirmationFooter, + }, +]; + +const mapStateToProps = createStructuredSelector({ + device: getCurrentDevice, +}); + +const mapDispatchToProps = { + closeModal, + openModal, +}; + +const Body = ({ + t, + stepId, + device, + closeModal, + openModal, + onChangeStepId, + params, + name, +}: Props) => { + const [optimisticOperation, setOptimisticOperation] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [signed, setSigned] = useState(false); + const dispatch = useDispatch(); + + const { + transaction, + setTransaction, + updateTransaction, + account, + parentAccount, + status, + bridgeError, + bridgePending, + } = useBridgeTransaction(() => { + const { account, stakeWithMeta } = params; + + invariant(account && account.solanaResources, "solana: account and solana resources required"); + + const bridge: AccountBridge = getAccountBridge(account, undefined); + + const transaction = bridge.updateTransaction(bridge.createTransaction(account), { + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: stakeWithMeta.stake.stakeAccAddr, + voteAccAddr: stakeWithMeta.stake.delegation?.voteAccAddr ?? "", + }, + }, + }); + + return { account, parentAccount: undefined, transaction }; + }); + + const handleCloseModal = useCallback(() => { + closeModal(name); + }, [closeModal, name]); + + const handleStepChange = useCallback(e => onChangeStepId(e.id), [onChangeStepId]); + + const handleRetry = useCallback(() => { + setTransactionError(null); + onChangeStepId("validator"); + }, [onChangeStepId]); + + const handleTransactionError = useCallback((error: Error) => { + if (!(error instanceof UserRefusedOnDevice)) { + logger.critical(error); + } + setTransactionError(error); + }, []); + + const handleOperationBroadcasted = useCallback( + (optimisticOperation: Operation) => { + if (!account) return; + dispatch( + updateAccountWithUpdater(account.id, account => + addPendingOperation(account, optimisticOperation), + ), + ); + setOptimisticOperation(optimisticOperation); + setTransactionError(null); + }, + [account, dispatch], + ); + + const error = transactionError || bridgeError; + + const errorSteps = []; + + if (transactionError) { + errorSteps.push(2); + } else if (bridgeError) { + errorSteps.push(0); + } + + const stepperProps = { + title: t("solana.delegation.activate.flow.title"), + device, + account, + parentAccount, + transaction, + signed, + stepId, + steps, + errorSteps, + disabledSteps: [], + hideBreadcrumb: !!error && ["validator"].includes(stepId), + onRetry: handleRetry, + onStepChange: handleStepChange, + onClose: handleCloseModal, + error, + status, + optimisticOperation, + openModal, + setSigned, + onChangeTransaction: setTransaction, + onUpdateTransaction: updateTransaction, + onOperationBroadcasted: handleOperationBroadcasted, + onTransactionError: handleTransactionError, + t, + bridgePending, + }; + + return ( + + + + + ); +}; + +const C: React$ComponentType = compose( + connect(mapStateToProps, mapDispatchToProps), + withTranslation(), +)(Body); + +export default C; diff --git a/src/renderer/families/solana/DelegationActivateFlowModal/index.js b/src/renderer/families/solana/DelegationActivateFlowModal/index.js new file mode 100644 index 0000000000..28456e400e --- /dev/null +++ b/src/renderer/families/solana/DelegationActivateFlowModal/index.js @@ -0,0 +1,47 @@ +// @flow + +import React, { PureComponent } from "react"; +import Modal from "~/renderer/components/Modal"; +import Body from "./Body"; +import type { StepId } from "./types"; +type State = { + stepId: StepId, +}; + +const INITIAL_STATE = { + stepId: "validator", +}; + +class DelegationActivateModal extends PureComponent<{ name: string }, State> { + state = INITIAL_STATE; + handleReset = () => this.setState({ ...INITIAL_STATE }); + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + render() { + const { stepId } = this.state; + const { name } = this.props; + + const isModalLocked = ["connectDevice", "confirmation"].includes(stepId); + + return ( + ( + + )} + /> + ); + } +} + +export default DelegationActivateModal; diff --git a/src/renderer/families/solana/DelegationActivateFlowModal/steps/StepConfirmation.js b/src/renderer/families/solana/DelegationActivateFlowModal/steps/StepConfirmation.js new file mode 100644 index 0000000000..910c777cc3 --- /dev/null +++ b/src/renderer/families/solana/DelegationActivateFlowModal/steps/StepConfirmation.js @@ -0,0 +1,108 @@ +// @flow + +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import React from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; +import Button from "~/renderer/components/Button"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import RetryButton from "~/renderer/components/RetryButton"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; +import { OperationDetails } from "~/renderer/drawers/OperationDetails"; +import { setDrawer } from "~/renderer/drawers/Provider"; +import { multiline } from "~/renderer/styles/helpers"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import type { StepProps } from "../types"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; +`; + +function StepConfirmation({ + account, + t, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + if (optimisticOperation) { + return ( + + + + + } + description={multiline(t("solana.delegation.statusUpdateNotice"))} + /> + + ); + } + + if (error) { + return ( + + + {signed ? ( + } /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + transitionTo, + account, + parentAccount, + onRetry, + error, + openModal, + onClose, + optimisticOperation, + transaction, +}: StepProps) { + return ( + + + {optimisticOperation ? ( + + ) : error ? ( + + ) : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/families/solana/DelegationActivateFlowModal/steps/StepValidator.js b/src/renderer/families/solana/DelegationActivateFlowModal/steps/StepValidator.js new file mode 100644 index 0000000000..ad8f23b876 --- /dev/null +++ b/src/renderer/families/solana/DelegationActivateFlowModal/steps/StepValidator.js @@ -0,0 +1,97 @@ +// @flow +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { AccountBridge } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React from "react"; +import { Trans } from "react-i18next"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import ErrorBanner from "~/renderer/components/ErrorBanner"; +import ErrorDisplay from "../../shared/components/ErrorDisplay"; +import LedgerByFigmentTC from "../../shared/components/LedgerByFigmentTCLink"; +import ValidatorsField from "../../shared/fields/ValidatorsField"; +import type { StepProps } from "../types"; + +export default function StepValidator({ + account, + parentAccount, + onUpdateTransaction, + transaction, + status, + error, + t, +}: StepProps) { + invariant( + account && account.solanaResources && transaction, + "solana account, resources and transaction required", + ); + const { solanaResources } = account; + + const updateValidator = ({ address }: { address: string }) => { + const bridge: AccountBridge = getAccountBridge(account, parentAccount); + onUpdateTransaction(tx => { + return bridge.updateTransaction(tx, { + model: { + ...tx.model, + uiState: { + ...tx.model.uiState, + voteAccAddr: address, + }, + }, + }); + }); + }; + + const chosenVoteAccAddr = transaction.model.uiState.voteAccAddr; + + return ( + + + {error && } + {status.errors.fee && } + + + ); +} + +export function StepValidatorFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + invariant(account, "account required"); + const { errors } = status; + const hasErrors = Object.keys(errors).length > 0; + const canNext = !bridgePending && !hasErrors; + + return ( + <> + + + + + + + ); +} diff --git a/src/renderer/families/solana/DelegationActivateFlowModal/types.js b/src/renderer/families/solana/DelegationActivateFlowModal/types.js new file mode 100644 index 0000000000..221a505bed --- /dev/null +++ b/src/renderer/families/solana/DelegationActivateFlowModal/types.js @@ -0,0 +1,32 @@ +// @flow +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Account, Operation, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import type { TFunction } from "react-i18next"; +import type { Step } from "~/renderer/components/Stepper"; + +export type StepId = "validator" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: ?Account, + parentAccount: ?Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + optimisticOperation: Operation, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onUpdateTransaction: ((Transaction) => Transaction) => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/families/solana/DelegationDeactivateFlowModal/Body.js b/src/renderer/families/solana/DelegationDeactivateFlowModal/Body.js new file mode 100644 index 0000000000..6eac333c66 --- /dev/null +++ b/src/renderer/families/solana/DelegationDeactivateFlowModal/Body.js @@ -0,0 +1,206 @@ +// @flow +import { UserRefusedOnDevice } from "@ledgerhq/errors"; +import { addPendingOperation } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import useBridgeTransaction from "@ledgerhq/live-common/lib/bridge/useBridgeTransaction"; +import type { StakeWithMeta, Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { AccountBridge, Operation, Account } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React, { useCallback, useState } from "react"; +import { Trans, withTranslation } from "react-i18next"; +import { connect, useDispatch } from "react-redux"; +import { compose } from "redux"; +import { createStructuredSelector } from "reselect"; +import logger from "~/logger/logger"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { closeModal, openModal } from "~/renderer/actions/modals"; +import Track from "~/renderer/analytics/Track"; +import Stepper from "~/renderer/components/Stepper"; +import GenericStepConnectDevice from "~/renderer/modals/Send/steps/GenericStepConnectDevice"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import StepConfirmation, { StepConfirmationFooter } from "./steps/StepConfirmation"; +import StepValidator, { StepValidatorFooter } from "./steps/StepValidator"; +import type { St, StepId } from "./types"; +import type { TFunction } from "react-i18next"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; + +type OwnProps = {| + stepId: StepId, + onClose: () => void, + onChangeStepId: StepId => void, + params: { + account: Account, + stakeWithMeta: StakeWithMeta, + parentAccount: ?Account, + }, + name: string, +|}; + +type StateProps = {| + t: TFunction, + device: ?Device, + accounts: Account[], + device: ?Device, + closeModal: string => void, + openModal: string => void, +|}; + +type Props = OwnProps & StateProps; + +const steps: Array = [ + { + id: "validator", + label: , + component: StepValidator, + footer: StepValidatorFooter, + }, + { + id: "connectDevice", + label: , + component: GenericStepConnectDevice, + }, + { + id: "confirmation", + label: , + component: StepConfirmation, + footer: StepConfirmationFooter, + }, +]; + +const mapStateToProps = createStructuredSelector({ + device: getCurrentDevice, +}); + +const mapDispatchToProps = { + closeModal, + openModal, +}; + +const Body = ({ + t, + stepId, + device, + closeModal, + openModal, + onChangeStepId, + params, + name, +}: Props) => { + const [optimisticOperation, setOptimisticOperation] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [signed, setSigned] = useState(false); + const dispatch = useDispatch(); + + const { + transaction, + setTransaction, + updateTransaction, + account, + parentAccount, + status, + bridgeError, + bridgePending, + } = useBridgeTransaction(() => { + const { account, stakeWithMeta } = params; + + invariant(account && account.solanaResources, "solana: account and solana resources required"); + + const bridge: AccountBridge = getAccountBridge(account, undefined); + + const transaction = bridge.updateTransaction(bridge.createTransaction(account), { + model: { + kind: "stake.undelegate", + uiState: { + stakeAccAddr: stakeWithMeta.stake.stakeAccAddr, + }, + }, + }); + + return { account, parentAccount: undefined, transaction }; + }); + + const handleCloseModal = useCallback(() => { + closeModal(name); + }, [closeModal, name]); + + const handleStepChange = useCallback(e => onChangeStepId(e.id), [onChangeStepId]); + + const handleRetry = useCallback(() => { + setTransactionError(null); + onChangeStepId("connectDevice"); + }, [onChangeStepId]); + + const handleTransactionError = useCallback((error: Error) => { + if (!(error instanceof UserRefusedOnDevice)) { + logger.critical(error); + } + setTransactionError(error); + }, []); + + const handleOperationBroadcasted = useCallback( + (optimisticOperation: Operation) => { + if (!account) return; + dispatch( + updateAccountWithUpdater(account.id, account => + addPendingOperation(account, optimisticOperation), + ), + ); + setOptimisticOperation(optimisticOperation); + setTransactionError(null); + }, + [account, dispatch], + ); + + const error = transactionError || bridgeError; + + const errorSteps = []; + + if (transactionError) { + errorSteps.push(2); + } else if (bridgeError) { + errorSteps.push(0); + } + + const stepperProps = { + title: t("solana.delegation.deactivate.flow.title"), + device, + account, + parentAccount, + transaction, + signed, + stepId, + steps, + errorSteps, + disabledSteps: [], + hideBreadcrumb: !!error, + onRetry: handleRetry, + onStepChange: handleStepChange, + onClose: handleCloseModal, + error, + status, + optimisticOperation, + openModal, + setSigned, + onChangeTransaction: setTransaction, + onUpdateTransaction: updateTransaction, + onOperationBroadcasted: handleOperationBroadcasted, + onTransactionError: handleTransactionError, + t, + bridgePending, + }; + + return ( + + + + + ); +}; + +const C: React$ComponentType = compose( + connect(mapStateToProps, mapDispatchToProps), + withTranslation(), +)(Body); + +export default C; diff --git a/src/renderer/families/solana/DelegationDeactivateFlowModal/index.js b/src/renderer/families/solana/DelegationDeactivateFlowModal/index.js new file mode 100644 index 0000000000..e3670f4a97 --- /dev/null +++ b/src/renderer/families/solana/DelegationDeactivateFlowModal/index.js @@ -0,0 +1,48 @@ +// @flow + +import React, { PureComponent } from "react"; +import Modal from "~/renderer/components/Modal"; +import Body from "./Body"; +import type { StepId } from "./types"; + +type State = { + stepId: StepId, +}; + +const INITIAL_STATE = { + stepId: "validator", +}; + +class DelegationDeactivateModal extends PureComponent<{ name: string }, State> { + state = INITIAL_STATE; + handleReset = () => this.setState({ ...INITIAL_STATE }); + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + render() { + const { stepId } = this.state; + const { name } = this.props; + + const isModalLocked = ["connectDevice", "confirmation"].includes(stepId); + + return ( + ( + + )} + /> + ); + } +} + +export default DelegationDeactivateModal; diff --git a/src/renderer/families/solana/DelegationDeactivateFlowModal/steps/StepConfirmation.js b/src/renderer/families/solana/DelegationDeactivateFlowModal/steps/StepConfirmation.js new file mode 100644 index 0000000000..b41ac79c57 --- /dev/null +++ b/src/renderer/families/solana/DelegationDeactivateFlowModal/steps/StepConfirmation.js @@ -0,0 +1,108 @@ +// @flow + +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import React from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; +import Button from "~/renderer/components/Button"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import RetryButton from "~/renderer/components/RetryButton"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; +import { OperationDetails } from "~/renderer/drawers/OperationDetails"; +import { setDrawer } from "~/renderer/drawers/Provider"; +import { multiline } from "~/renderer/styles/helpers"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import type { StepProps } from "../types"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; +`; + +function StepConfirmation({ + account, + t, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + if (optimisticOperation) { + return ( + + + + + } + description={multiline(t("solana.delegation.statusUpdateNotice"))} + /> + + ); + } + + if (error) { + return ( + + + {signed ? ( + } /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + transitionTo, + account, + parentAccount, + onRetry, + error, + openModal, + onClose, + optimisticOperation, + transaction, +}: StepProps) { + return ( + + + {optimisticOperation ? ( + + ) : error ? ( + + ) : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/families/solana/DelegationDeactivateFlowModal/steps/StepValidator.js b/src/renderer/families/solana/DelegationDeactivateFlowModal/steps/StepValidator.js new file mode 100644 index 0000000000..8b38f028d5 --- /dev/null +++ b/src/renderer/families/solana/DelegationDeactivateFlowModal/steps/StepValidator.js @@ -0,0 +1,112 @@ +// @flow +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import { + useLedgerFirstShuffledValidators, + useSolanaStakesWithMeta, +} from "@ledgerhq/live-common/lib/families/solana/react"; +import { BigNumber } from "bignumber.js"; +import invariant from "invariant"; +import React from "react"; +import { Trans } from "react-i18next"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import ErrorBanner from "~/renderer/components/ErrorBanner"; +import FirstLetterIcon from "~/renderer/components/FirstLetterIcon"; +import Image from "~/renderer/components/Image"; +import AccountFooter from "~/renderer/modals/Send/AccountFooter"; +import { Ellipsis } from "../../shared/components/Ellipsis"; +import ErrorDisplay from "../../shared/components/ErrorDisplay"; +import ValidatorRow from "../../shared/components/ValidatorRow"; +import type { StepProps } from "../types"; + +export default function StepValidator({ + account, + parentAccount, + onUpdateTransaction, + transaction, + status, + error, + t, +}: StepProps) { + if (account === null || transaction === null || account?.solanaResources === undefined) { + throw new Error("account, transaction and solana resouces required"); + } + + const { solanaResources } = account; + + if (transaction?.model.kind !== "stake.undelegate") { + throw new Error("unsupported transaction"); + } + + const { stakeAccAddr } = transaction.model.uiState; + + const stakesWithMeta = useSolanaStakesWithMeta(account.currency, solanaResources.stakes); + + const stakeWithMeta = stakesWithMeta.find(s => s.stake.stakeAccAddr === stakeAccAddr); + + if (stakeWithMeta === undefined) { + throw new Error(`stake with account address <${stakeAccAddr}> not found`); + } + + const { meta, stake } = stakeWithMeta; + const validatorName = meta.validator?.name ?? stakeAccAddr; + + const unit = getAccountUnit(account); + + const validators = useLedgerFirstShuffledValidators(account.currency); + const validator = validators.find(v => v.voteAccount === stake.delegation?.voteAccAddr); + + if (validator === undefined) { + return null; + } + + return ( + + + {error && } + + {status.errors.fee && } + + ); +} + +export function StepValidatorFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + invariant(account, "account required"); + const { errors } = status; + const hasErrors = Object.keys(errors).length > 0; + const canNext = !bridgePending && !hasErrors; + + return ( + <> + + + + + + + ); +} diff --git a/src/renderer/families/solana/DelegationDeactivateFlowModal/types.js b/src/renderer/families/solana/DelegationDeactivateFlowModal/types.js new file mode 100644 index 0000000000..221a505bed --- /dev/null +++ b/src/renderer/families/solana/DelegationDeactivateFlowModal/types.js @@ -0,0 +1,32 @@ +// @flow +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Account, Operation, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import type { TFunction } from "react-i18next"; +import type { Step } from "~/renderer/components/Stepper"; + +export type StepId = "validator" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: ?Account, + parentAccount: ?Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + optimisticOperation: Operation, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onUpdateTransaction: ((Transaction) => Transaction) => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/families/solana/DelegationFlowModal/Body.js b/src/renderer/families/solana/DelegationFlowModal/Body.js new file mode 100644 index 0000000000..14aea1aab4 --- /dev/null +++ b/src/renderer/families/solana/DelegationFlowModal/Body.js @@ -0,0 +1,219 @@ +// @flow +import { UserRefusedOnDevice } from "@ledgerhq/errors"; +import { addPendingOperation } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import useBridgeTransaction from "@ledgerhq/live-common/lib/bridge/useBridgeTransaction"; +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { AccountBridge, Operation, Account } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React, { useCallback, useState } from "react"; +import { Trans, withTranslation } from "react-i18next"; +import type { TFunction } from "react-i18next"; +import { connect, useDispatch } from "react-redux"; +import { compose } from "redux"; +import { createStructuredSelector } from "reselect"; +import logger from "~/logger/logger"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { closeModal, openModal } from "~/renderer/actions/modals"; +import Track from "~/renderer/analytics/Track"; +import Stepper from "~/renderer/components/Stepper"; +import GenericStepConnectDevice from "~/renderer/modals/Send/steps/GenericStepConnectDevice"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import StepAmount, { StepAmountFooter } from "./steps/StepAmount"; +import StepConfirmation, { StepConfirmationFooter } from "./steps/StepConfirmation"; +import StepValidator, { StepValidatorFooter } from "./steps/StepValidator"; +import type { St, StepProps, StepId } from "./types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { defaultVoteAccAddrByCurrencyId } from "@ledgerhq/live-common/lib/families/solana/utils"; + +type OwnProps = {| + stepId: StepId, + onClose: () => void, + onChangeStepId: StepId => void, + params: { + account: Account, + parentAccount: ?Account, + }, + name: string, +|}; + +type StateProps = {| + t: TFunction, + device: ?Device, + accounts: Account[], + device: ?Device, + closeModal: string => void, + openModal: string => void, +|}; + +type Props = OwnProps & StateProps; + +const steps: Array = [ + { + id: "validator", + label: , + component: StepValidator, + noScroll: true, + footer: StepValidatorFooter, + }, + { + id: "amount", + label: , + component: StepAmount, + onBack: ({ transitionTo }: StepProps) => transitionTo("validator"), + noScroll: true, + footer: StepAmountFooter, + }, + { + id: "connectDevice", + label: , + component: GenericStepConnectDevice, + onBack: ({ transitionTo }: StepProps) => transitionTo("amount"), + }, + { + id: "confirmation", + label: , + component: StepConfirmation, + footer: StepConfirmationFooter, + }, +]; + +const mapStateToProps = createStructuredSelector({ + device: getCurrentDevice, +}); + +const mapDispatchToProps = { + closeModal, + openModal, +}; + +const Body = ({ + t, + stepId, + device, + closeModal, + openModal, + onChangeStepId, + params, + name, +}: Props) => { + const [optimisticOperation, setOptimisticOperation] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [signed, setSigned] = useState(false); + const dispatch = useDispatch(); + + const { + transaction, + setTransaction, + updateTransaction, + account, + parentAccount, + status, + bridgeError, + bridgePending, + } = useBridgeTransaction(() => { + const { account } = params; + + invariant(account && account.solanaResources, "solana: account and solana resources required"); + + const bridge: AccountBridge = getAccountBridge(account, undefined); + + const transaction = bridge.updateTransaction(bridge.createTransaction(account), { + model: { + kind: "stake.createAccount", + uiState: { + delegate: { + voteAccAddress: defaultVoteAccAddrByCurrencyId(account.currency.id) ?? "", + }, + }, + }, + }); + + return { account, parentAccount: undefined, transaction }; + }); + + const handleCloseModal = useCallback(() => { + closeModal(name); + }, [closeModal, name]); + + const handleStepChange = useCallback(e => onChangeStepId(e.id), [onChangeStepId]); + + const handleRetry = useCallback(() => { + setTransactionError(null); + onChangeStepId("connectDevice"); + }, [onChangeStepId]); + + const handleTransactionError = useCallback((error: Error) => { + if (!(error instanceof UserRefusedOnDevice)) { + logger.critical(error); + } + setTransactionError(error); + }, []); + + const handleOperationBroadcasted = useCallback( + (optimisticOperation: Operation) => { + if (!account) return; + dispatch( + updateAccountWithUpdater(account.id, account => + addPendingOperation(account, optimisticOperation), + ), + ); + setOptimisticOperation(optimisticOperation); + setTransactionError(null); + }, + [account, dispatch], + ); + + const error = transactionError || bridgeError; + + const errorSteps = []; + + if (transactionError) { + errorSteps.push(2); + } else if (bridgeError) { + errorSteps.push(0); + } + + const stepperProps = { + title: t("solana.delegation.flow.title"), + device, + account, + parentAccount, + transaction, + signed, + stepId, + steps, + errorSteps, + disabledSteps: [], + hideBreadcrumb: !!error && ["validator"].includes(stepId), + onRetry: handleRetry, + onStepChange: handleStepChange, + onClose: handleCloseModal, + error, + status, + optimisticOperation, + openModal, + setSigned, + onChangeTransaction: setTransaction, + onUpdateTransaction: updateTransaction, + onOperationBroadcasted: handleOperationBroadcasted, + onTransactionError: handleTransactionError, + t, + bridgePending, + }; + + return ( + + + + + ); +}; + +const C: React$ComponentType = compose( + connect(mapStateToProps, mapDispatchToProps), + withTranslation(), +)(Body); + +export default C; diff --git a/src/renderer/families/solana/DelegationFlowModal/Info/index.js b/src/renderer/families/solana/DelegationFlowModal/Info/index.js new file mode 100644 index 0000000000..527e6299c3 --- /dev/null +++ b/src/renderer/families/solana/DelegationFlowModal/Info/index.js @@ -0,0 +1,50 @@ +// @flow +import type { Account, AccountLike } from "@ledgerhq/live-common/lib/types"; +import React, { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; +import { urls } from "~/config/urls"; +import { closeModal, openModal } from "~/renderer/actions/modals"; +import EarnRewardsInfoModal from "~/renderer/components/EarnRewardsInfoModal"; +import LinkWithExternalIcon from "~/renderer/components/LinkWithExternalIcon"; +import WarnBox from "~/renderer/components/WarnBox"; +import { openURL } from "~/renderer/linking"; + +type Props = { + name?: string, + account: AccountLike, + parentAccount: ?Account, +}; + +export default function SolanaEarnRewardsInfoModal({ name, account, parentAccount }: Props) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const onNext = useCallback(() => { + dispatch(closeModal(name)); + dispatch( + openModal("MODAL_SOLANA_DELEGATE", { + parentAccount, + account, + }), + ); + }, [parentAccount, account, dispatch, name]); + + const onLearnMore = useCallback(() => { + openURL(urls.solana.staking); + }, []); + + return ( + {t("solana.delegation.earnRewards.warning")}} + footerLeft={} + /> + ); +} diff --git a/src/renderer/families/solana/DelegationFlowModal/index.js b/src/renderer/families/solana/DelegationFlowModal/index.js new file mode 100644 index 0000000000..f06ecb04a0 --- /dev/null +++ b/src/renderer/families/solana/DelegationFlowModal/index.js @@ -0,0 +1,50 @@ +// @flow + +import React, { PureComponent } from "react"; +import Modal from "~/renderer/components/Modal"; +import Body from "./Body"; +import type { StepId } from "./types"; +type State = { + stepId: StepId, +}; + +const INITIAL_STATE = { + stepId: "validator", +}; + +class DelegationModal extends PureComponent<{ name: string }, State> { + state = INITIAL_STATE; + + handleReset = () => this.setState({ ...INITIAL_STATE }); + + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + + render() { + const { stepId } = this.state; + const { name } = this.props; + + const isModalLocked = ["connectDevice", "confirmation"].includes(stepId); + + return ( + ( + + )} + /> + ); + } +} + +export default DelegationModal; diff --git a/src/renderer/families/solana/DelegationFlowModal/steps/StepAmount.js b/src/renderer/families/solana/DelegationFlowModal/steps/StepAmount.js new file mode 100644 index 0000000000..c92dddbdea --- /dev/null +++ b/src/renderer/families/solana/DelegationFlowModal/steps/StepAmount.js @@ -0,0 +1,91 @@ +// @flow + +import { getMainAccount } from "@ledgerhq/live-common/lib/account"; +import React, { Fragment, PureComponent } from "react"; +import { Trans } from "react-i18next"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import CurrencyDownStatusAlert from "~/renderer/components/CurrencyDownStatusAlert"; +import ErrorBanner from "~/renderer/components/ErrorBanner"; +import SpendableBanner from "~/renderer/components/SpendableBanner"; +import AccountFooter from "~/renderer/modals/Send/AccountFooter"; +import AmountField from "~/renderer/modals/Send/fields/AmountField"; +import type { StepProps } from "../types"; + +const StepAmount = ({ + t, + account, + parentAccount, + transaction, + onChangeTransaction, + error, + status, + bridgePending, +}: StepProps) => { + if (!status) return null; + const mainAccount = account ? getMainAccount(account, parentAccount) : null; + + return ( + + + {mainAccount ? : null} + {error ? : null} + {account && transaction && mainAccount && ( + + {account && transaction ? ( + + ) : null} + + + )} + + ); +}; + +export class StepAmountFooter extends PureComponent { + onNext = async () => { + const { transitionTo } = this.props; + transitionTo("connectDevice"); + }; + + render() { + const { account, parentAccount, status, bridgePending } = this.props; + const { errors } = status; + if (!account) return null; + + const mainAccount = getMainAccount(account, parentAccount); + const isTerminated = mainAccount.currency.terminated; + const hasErrors = Object.keys(errors).length; + const canNext = !bridgePending && !hasErrors && !isTerminated; + + return ( + <> + + + + ); + } +} + +export default StepAmount; diff --git a/src/renderer/families/solana/DelegationFlowModal/steps/StepConfirmation.js b/src/renderer/families/solana/DelegationFlowModal/steps/StepConfirmation.js new file mode 100644 index 0000000000..a74085cb46 --- /dev/null +++ b/src/renderer/families/solana/DelegationFlowModal/steps/StepConfirmation.js @@ -0,0 +1,106 @@ +// @flow + +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import React from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; +import Button from "~/renderer/components/Button"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import RetryButton from "~/renderer/components/RetryButton"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; +import { OperationDetails } from "~/renderer/drawers/OperationDetails"; +import { setDrawer } from "~/renderer/drawers/Provider"; +import { multiline } from "~/renderer/styles/helpers"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import type { StepProps } from "../types"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; +`; + +function StepConfirmation({ + account, + t, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + if (optimisticOperation) { + return ( + + + + } + description={multiline(t("solana.delegation.statusUpdateNotice"))} + /> + + ); + } + + if (error) { + return ( + + + {signed ? ( + } /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + transitionTo, + account, + parentAccount, + onRetry, + error, + openModal, + onClose, + optimisticOperation, + transaction, +}: StepProps) { + return ( + + + {optimisticOperation ? ( + + ) : error ? ( + + ) : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/families/solana/DelegationFlowModal/steps/StepValidator.js b/src/renderer/families/solana/DelegationFlowModal/steps/StepValidator.js new file mode 100644 index 0000000000..3218df576d --- /dev/null +++ b/src/renderer/families/solana/DelegationFlowModal/steps/StepValidator.js @@ -0,0 +1,95 @@ +// @flow +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { AccountBridge } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React from "react"; +import { Trans } from "react-i18next"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import ErrorBanner from "~/renderer/components/ErrorBanner"; +import LedgerByFigmentTC from "../../shared/components/LedgerByFigmentTCLink"; +import ValidatorsField from "../../shared/fields/ValidatorsField"; +import type { StepProps } from "../types"; + +export default function StepValidator({ + account, + parentAccount, + onUpdateTransaction, + transaction, + status, + error, + t, +}: StepProps) { + invariant( + account && account.solanaResources && transaction, + "solana account, resources and transaction required", + ); + const { solanaResources } = account; + + const updateValidator = ({ address }: { address: string }) => { + const bridge: AccountBridge = getAccountBridge(account, parentAccount); + onUpdateTransaction(tx => { + return bridge.updateTransaction(transaction, { + model: { + kind: "stake.createAccount", + uiState: { + delegate: { + voteAccAddress: address, + }, + }, + }, + }); + }); + }; + + const chosenVoteAccAddr = transaction.model.uiState.delegate?.voteAccAddress; + + return ( + + + {error && } + + + ); +} + +export function StepValidatorFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + invariant(account, "account required"); + const { errors } = status; + const canNext = !bridgePending && !errors.voteAccAddr; + + return ( + <> + + + + + + + ); +} diff --git a/src/renderer/families/solana/DelegationFlowModal/types.js b/src/renderer/families/solana/DelegationFlowModal/types.js new file mode 100644 index 0000000000..fd2f41d5b9 --- /dev/null +++ b/src/renderer/families/solana/DelegationFlowModal/types.js @@ -0,0 +1,32 @@ +// @flow +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Account, Operation, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import type { TFunction } from "react-i18next"; +import type { Step } from "~/renderer/components/Stepper"; + +export type StepId = "validator" | "amount" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: ?Account, + parentAccount: ?Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + optimisticOperation: Operation, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onUpdateTransaction: ((Transaction) => Transaction) => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/families/solana/DelegationReactivateFlowModal/Body.js b/src/renderer/families/solana/DelegationReactivateFlowModal/Body.js new file mode 100644 index 0000000000..6fd0f37719 --- /dev/null +++ b/src/renderer/families/solana/DelegationReactivateFlowModal/Body.js @@ -0,0 +1,216 @@ +// @flow +import { UserRefusedOnDevice } from "@ledgerhq/errors"; +import { addPendingOperation } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import useBridgeTransaction from "@ledgerhq/live-common/lib/bridge/useBridgeTransaction"; +import type { + Transaction, + SolanaStakeWithMeta, +} from "@ledgerhq/live-common/lib/families/solana/types"; +import type { AccountBridge, Operation, Account } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React, { useCallback, useState } from "react"; +import { Trans, withTranslation } from "react-i18next"; +import type { TFunction } from "react-i18next"; +import { connect, useDispatch } from "react-redux"; +import { compose } from "redux"; +import { createStructuredSelector } from "reselect"; +import logger from "~/logger/logger"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { closeModal, openModal } from "~/renderer/actions/modals"; +import Track from "~/renderer/analytics/Track"; +import Stepper from "~/renderer/components/Stepper"; +import GenericStepConnectDevice from "~/renderer/modals/Send/steps/GenericStepConnectDevice"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import StepConfirmation, { StepConfirmationFooter } from "./steps/StepConfirmation"; +import StepValidator, { StepValidatorFooter } from "./steps/StepValidator"; +import type { St, StepId } from "./types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; + +type OwnProps = {| + stepId: StepId, + onClose: () => void, + onChangeStepId: StepId => void, + params: { + account: Account, + stakeWithMeta: SolanaStakeWithMeta, + parentAccount: ?Account, + }, + name: string, +|}; + +type StateProps = {| + t: TFunction, + device: ?Device, + accounts: Account[], + device: ?Device, + closeModal: string => void, + openModal: string => void, +|}; + +type Props = OwnProps & StateProps; + +const steps: Array = [ + { + id: "validator", + label: , + component: StepValidator, + footer: StepValidatorFooter, + }, + { + id: "connectDevice", + label: , + component: GenericStepConnectDevice, + }, + { + id: "confirmation", + label: , + component: StepConfirmation, + footer: StepConfirmationFooter, + }, +]; + +const mapStateToProps = createStructuredSelector({ + device: getCurrentDevice, +}); + +const mapDispatchToProps = { + closeModal, + openModal, +}; + +const Body = ({ + t, + stepId, + device, + closeModal, + openModal, + onChangeStepId, + params, + name, +}: Props) => { + const [optimisticOperation, setOptimisticOperation] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [signed, setSigned] = useState(false); + const dispatch = useDispatch(); + + const { + transaction, + setTransaction, + updateTransaction, + account, + parentAccount, + status, + bridgeError, + bridgePending, + } = useBridgeTransaction(() => { + const { account, stakeWithMeta } = params; + const { stake } = stakeWithMeta; + + invariant(account && account.solanaResources, "solana: account and solana resources required"); + + invariant( + stake.delegation && stake.activation.state === "deactivating", + "solana: can reactivate only delegated stake in state", + ); + + const bridge: AccountBridge = getAccountBridge(account, undefined); + + const transaction = bridge.updateTransaction(bridge.createTransaction(account), { + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: stake.stakeAccAddr, + voteAccAddr: stake.delegation.voteAccAddr, + }, + }, + }); + + return { account, parentAccount: undefined, transaction }; + }); + + const handleCloseModal = useCallback(() => { + closeModal(name); + }, [closeModal, name]); + + const handleStepChange = useCallback(e => onChangeStepId(e.id), [onChangeStepId]); + + const handleRetry = useCallback(() => { + setTransactionError(null); + onChangeStepId("connectDevice"); + }, [onChangeStepId]); + + const handleTransactionError = useCallback((error: Error) => { + if (!(error instanceof UserRefusedOnDevice)) { + logger.critical(error); + } + setTransactionError(error); + }, []); + + const handleOperationBroadcasted = useCallback( + (optimisticOperation: Operation) => { + if (!account) return; + dispatch( + updateAccountWithUpdater(account.id, account => + addPendingOperation(account, optimisticOperation), + ), + ); + setOptimisticOperation(optimisticOperation); + setTransactionError(null); + }, + [account, dispatch], + ); + + const error = transactionError || bridgeError; + + const errorSteps = []; + + if (transactionError) { + errorSteps.push(2); + } else if (bridgeError) { + errorSteps.push(0); + } + + const stepperProps = { + title: t("solana.delegation.reactivate.flow.title"), + device, + account, + parentAccount, + transaction, + signed, + stepId, + steps, + errorSteps, + disabledSteps: [], + hideBreadcrumb: !!error, + onRetry: handleRetry, + onStepChange: handleStepChange, + onClose: handleCloseModal, + error, + status, + optimisticOperation, + openModal, + setSigned, + onChangeTransaction: setTransaction, + onUpdateTransaction: updateTransaction, + onOperationBroadcasted: handleOperationBroadcasted, + onTransactionError: handleTransactionError, + t, + bridgePending, + }; + + return ( + + + + + ); +}; + +const C: React$ComponentType = compose( + connect(mapStateToProps, mapDispatchToProps), + withTranslation(), +)(Body); + +export default C; diff --git a/src/renderer/families/solana/DelegationReactivateFlowModal/index.js b/src/renderer/families/solana/DelegationReactivateFlowModal/index.js new file mode 100644 index 0000000000..0df568868d --- /dev/null +++ b/src/renderer/families/solana/DelegationReactivateFlowModal/index.js @@ -0,0 +1,48 @@ +// @flow + +import React, { PureComponent } from "react"; +import Modal from "~/renderer/components/Modal"; +import Body from "./Body"; +import type { StepId } from "./types"; + +type State = { + stepId: StepId, +}; + +const INITIAL_STATE = { + stepId: "validator", +}; + +class DelegationReactivateModal extends PureComponent<{ name: string }, State> { + state = INITIAL_STATE; + handleReset = () => this.setState({ ...INITIAL_STATE }); + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + render() { + const { stepId } = this.state; + const { name } = this.props; + + const isModalLocked = ["connectDevice", "confirmation"].includes(stepId); + + return ( + ( + + )} + /> + ); + } +} + +export default DelegationReactivateModal; diff --git a/src/renderer/families/solana/DelegationReactivateFlowModal/steps/StepConfirmation.js b/src/renderer/families/solana/DelegationReactivateFlowModal/steps/StepConfirmation.js new file mode 100644 index 0000000000..bb8e48fe76 --- /dev/null +++ b/src/renderer/families/solana/DelegationReactivateFlowModal/steps/StepConfirmation.js @@ -0,0 +1,108 @@ +// @flow + +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import React from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; +import Button from "~/renderer/components/Button"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import RetryButton from "~/renderer/components/RetryButton"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; +import { OperationDetails } from "~/renderer/drawers/OperationDetails"; +import { setDrawer } from "~/renderer/drawers/Provider"; +import { multiline } from "~/renderer/styles/helpers"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import type { StepProps } from "../types"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; +`; + +function StepConfirmation({ + account, + t, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + if (optimisticOperation) { + return ( + + + + + } + description={multiline(t("solana.delegation.statusUpdateNotice"))} + /> + + ); + } + + if (error) { + return ( + + + {signed ? ( + } /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + transitionTo, + account, + parentAccount, + onRetry, + error, + openModal, + onClose, + optimisticOperation, + transaction, +}: StepProps) { + return ( + + + {optimisticOperation ? ( + + ) : error ? ( + + ) : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/families/solana/DelegationReactivateFlowModal/steps/StepValidator.js b/src/renderer/families/solana/DelegationReactivateFlowModal/steps/StepValidator.js new file mode 100644 index 0000000000..52240b1a63 --- /dev/null +++ b/src/renderer/families/solana/DelegationReactivateFlowModal/steps/StepValidator.js @@ -0,0 +1,110 @@ +// @flow +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import { useSolanaStakesWithMeta } from "@ledgerhq/live-common/lib/families/solana/react"; +import { BigNumber } from "bignumber.js"; +import invariant from "invariant"; +import React from "react"; +import { Trans } from "react-i18next"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import ErrorBanner from "~/renderer/components/ErrorBanner"; +import FirstLetterIcon from "~/renderer/components/FirstLetterIcon"; +import Image from "~/renderer/components/Image"; +import AccountFooter from "~/renderer/modals/Send/AccountFooter"; +import { Ellipsis } from "../../shared/components/Ellipsis"; +import ErrorDisplay from "../../shared/components/ErrorDisplay"; +import type { StepProps } from "../types"; +import ValidatorRow from "../../shared/components/ValidatorRow"; +import { useLedgerFirstShuffledValidators } from "@ledgerhq/live-common/lib/families/solana/react"; + +export default function StepValidator({ + account, + parentAccount, + onUpdateTransaction, + transaction, + status, + error, + t, +}: StepProps) { + if (account === null || transaction === null || account?.solanaResources === undefined) { + throw new Error("account, transaction and solana resouces required"); + } + + const { solanaResources } = account; + + if (transaction?.model.kind !== "stake.delegate") { + throw new Error("unsupported transaction"); + } + + const { stakeAccAddr } = transaction.model.uiState; + + const stakesWithMeta = useSolanaStakesWithMeta(account.currency, solanaResources.stakes); + + const stakeWithMeta = stakesWithMeta.find(s => s.stake.stakeAccAddr === stakeAccAddr); + + if (stakeWithMeta === undefined) { + throw new Error(`stake with account address <${stakeAccAddr}> not found`); + } + + const { meta, stake } = stakeWithMeta; + + const unit = getAccountUnit(account); + + const validators = useLedgerFirstShuffledValidators(account.currency); + const validator = validators.find(v => v.voteAccount === stake.delegation?.voteAccAddr); + + if (validator === undefined) { + return null; + } + + return ( + + + {error && } + + {status.errors.fee && } + + ); +} + +export function StepValidatorFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + invariant(account, "account required"); + const { errors } = status; + const hasErrors = Object.keys(errors).length > 0; + const canNext = !bridgePending && !hasErrors; + + return ( + <> + + + + + + + ); +} diff --git a/src/renderer/families/solana/DelegationReactivateFlowModal/types.js b/src/renderer/families/solana/DelegationReactivateFlowModal/types.js new file mode 100644 index 0000000000..221a505bed --- /dev/null +++ b/src/renderer/families/solana/DelegationReactivateFlowModal/types.js @@ -0,0 +1,32 @@ +// @flow +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Account, Operation, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import type { TFunction } from "react-i18next"; +import type { Step } from "~/renderer/components/Stepper"; + +export type StepId = "validator" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: ?Account, + parentAccount: ?Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + optimisticOperation: Operation, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onUpdateTransaction: ((Transaction) => Transaction) => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/families/solana/DelegationWithdrawFlowModal/Body.js b/src/renderer/families/solana/DelegationWithdrawFlowModal/Body.js new file mode 100644 index 0000000000..943e26e8bd --- /dev/null +++ b/src/renderer/families/solana/DelegationWithdrawFlowModal/Body.js @@ -0,0 +1,219 @@ +// @flow +import { UserRefusedOnDevice } from "@ledgerhq/errors"; +import { addPendingOperation } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import useBridgeTransaction from "@ledgerhq/live-common/lib/bridge/useBridgeTransaction"; +import type { + Transaction, + SolanaStakeWithMeta, +} from "@ledgerhq/live-common/lib/families/solana/types"; +import type { AccountBridge, Operation, Account } from "@ledgerhq/live-common/lib/types"; +import { BigNumber } from "bignumber.js"; +import invariant from "invariant"; +import React, { useCallback, useState } from "react"; +import { Trans, withTranslation } from "react-i18next"; +import type { TFunction } from "react-i18next"; +import { connect, useDispatch } from "react-redux"; +import { compose } from "redux"; +import { createStructuredSelector } from "reselect"; +import logger from "~/logger/logger"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { closeModal, openModal } from "~/renderer/actions/modals"; +import Track from "~/renderer/analytics/Track"; +import Stepper from "~/renderer/components/Stepper"; +import GenericStepConnectDevice from "~/renderer/modals/Send/steps/GenericStepConnectDevice"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import StepAmount, { StepAmountFooter } from "./steps/StepAmount"; +import StepConfirmation, { StepConfirmationFooter } from "./steps/StepConfirmation"; +import type { St, StepProps, StepId } from "./types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; + +type OwnProps = {| + stepId: StepId, + onClose: () => void, + onChangeStepId: StepId => void, + params: { + account: Account, + stakeWithMeta: SolanaStakeWithMeta, + parentAccount: ?Account, + }, + name: string, +|}; + +type StateProps = {| + t: TFunction, + device: ?Device, + accounts: Account[], + device: ?Device, + closeModal: string => void, + openModal: string => void, +|}; + +type Props = OwnProps & StateProps; + +const steps: Array = [ + { + id: "amount", + label: , + component: StepAmount, + noScroll: true, + footer: StepAmountFooter, + }, + { + id: "connectDevice", + label: , + component: GenericStepConnectDevice, + onBack: ({ transitionTo }: StepProps) => transitionTo("amount"), + }, + { + id: "confirmation", + label: , + component: StepConfirmation, + footer: StepConfirmationFooter, + }, +]; + +const mapStateToProps = createStructuredSelector({ + device: getCurrentDevice, +}); + +const mapDispatchToProps = { + closeModal, + openModal, +}; + +const Body = ({ + t, + stepId, + device, + closeModal, + openModal, + onChangeStepId, + params, + name, +}: Props) => { + const [optimisticOperation, setOptimisticOperation] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [signed, setSigned] = useState(false); + const dispatch = useDispatch(); + + const { + transaction, + setTransaction, + updateTransaction, + account, + parentAccount, + status, + bridgeError, + bridgePending, + } = useBridgeTransaction(() => { + const { account, stakeWithMeta } = params; + const { stake } = stakeWithMeta; + + invariant(account && account.solanaResources, "solana: account and solana resources required"); + + invariant( + stake.withdrawable > 0, + "solana: can withdraw only if there is something to withdraw", + ); + + const bridge: AccountBridge = getAccountBridge(account, undefined); + + const transaction = bridge.updateTransaction(bridge.createTransaction(account), { + amount: new BigNumber(stake.withdrawable), + model: { + kind: "stake.withdraw", + uiState: { + stakeAccAddr: stake.stakeAccAddr, + }, + }, + }); + + return { account, parentAccount: undefined, transaction }; + }); + + const handleCloseModal = useCallback(() => { + closeModal(name); + }, [closeModal, name]); + + const handleStepChange = useCallback(e => onChangeStepId(e.id), [onChangeStepId]); + + const handleRetry = useCallback(() => { + setTransactionError(null); + onChangeStepId("amount"); + }, [onChangeStepId]); + + const handleTransactionError = useCallback((error: Error) => { + if (!(error instanceof UserRefusedOnDevice)) { + logger.critical(error); + } + setTransactionError(error); + }, []); + + const handleOperationBroadcasted = useCallback( + (optimisticOperation: Operation) => { + if (!account) return; + dispatch( + updateAccountWithUpdater(account.id, account => + addPendingOperation(account, optimisticOperation), + ), + ); + setOptimisticOperation(optimisticOperation); + setTransactionError(null); + }, + [account, dispatch], + ); + + const error = transactionError || bridgeError; + + const errorSteps = []; + + if (transactionError) { + errorSteps.push(2); + } else if (bridgeError) { + errorSteps.push(0); + } + + const stepperProps = { + title: t("solana.delegation.withdraw.flow.title"), + device, + account, + parentAccount, + transaction, + signed, + stepId, + steps, + errorSteps, + disabledSteps: [], + hideBreadcrumb: !!error && ["amount"].includes(stepId), + onRetry: handleRetry, + onStepChange: handleStepChange, + onClose: handleCloseModal, + error, + status, + optimisticOperation, + openModal, + setSigned, + onChangeTransaction: setTransaction, + onUpdateTransaction: updateTransaction, + onOperationBroadcasted: handleOperationBroadcasted, + onTransactionError: handleTransactionError, + t, + bridgePending, + }; + + return ( + + + + + ); +}; + +const C: React$ComponentType = compose( + connect(mapStateToProps, mapDispatchToProps), + withTranslation(), +)(Body); + +export default C; diff --git a/src/renderer/families/solana/DelegationWithdrawFlowModal/fields/AmountField.js b/src/renderer/families/solana/DelegationWithdrawFlowModal/fields/AmountField.js new file mode 100644 index 0000000000..6922a9af0a --- /dev/null +++ b/src/renderer/families/solana/DelegationWithdrawFlowModal/fields/AmountField.js @@ -0,0 +1,41 @@ +// @flow + +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import type { Account, Transaction, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React from "react"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import InputCurrency from "~/renderer/components/InputCurrency"; + +type Props = { + account: Account, + transaction: Transaction, + status: TransactionStatus, +}; + +const InputRight = styled(Box).attrs(() => ({ + ff: "Inter", + color: "palette.text.shade80", + fontSize: 4, + justifyContent: "center", + pr: 3, +}))``; + +export default function AmountField({ account, transaction, status }: Props) { + invariant(transaction.family === "solana", "AmountField: solana family expected"); + + const defaultUnit = getAccountUnit(account); + + return ( + {defaultUnit.code}} + containerProps={{ grow: true }} + value={transaction.amount} + onChange={() => {}} + /> + ); +} diff --git a/src/renderer/families/solana/DelegationWithdrawFlowModal/index.js b/src/renderer/families/solana/DelegationWithdrawFlowModal/index.js new file mode 100644 index 0000000000..bc7fd94e2e --- /dev/null +++ b/src/renderer/families/solana/DelegationWithdrawFlowModal/index.js @@ -0,0 +1,50 @@ +// @flow + +import React, { PureComponent } from "react"; +import Modal from "~/renderer/components/Modal"; +import Body from "./Body"; +import type { StepId } from "./types"; +type State = { + stepId: StepId, +}; + +const INITIAL_STATE = { + stepId: "amount", +}; + +class DelegationModal extends PureComponent<{ name: string }, State> { + state = INITIAL_STATE; + + handleReset = () => this.setState({ ...INITIAL_STATE }); + + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + + render() { + const { stepId } = this.state; + const { name } = this.props; + + const isModalLocked = ["connectDevice", "confirmation"].includes(stepId); + + return ( + ( + + )} + /> + ); + } +} + +export default DelegationModal; diff --git a/src/renderer/families/solana/DelegationWithdrawFlowModal/steps/StepAmount.js b/src/renderer/families/solana/DelegationWithdrawFlowModal/steps/StepAmount.js new file mode 100644 index 0000000000..5de62c8e4e --- /dev/null +++ b/src/renderer/families/solana/DelegationWithdrawFlowModal/steps/StepAmount.js @@ -0,0 +1,66 @@ +// @flow +import invariant from "invariant"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import ErrorBanner from "~/renderer/components/ErrorBanner"; +import Label from "~/renderer/components/Label"; +import AccountFooter from "~/renderer/modals/Send/AccountFooter"; +import ErrorDisplay from "../../shared/components/ErrorDisplay"; +import AmountField from "../fields/AmountField"; +import type { StepProps } from "../types"; + +export default function StepAmount({ + account, + transaction, + bridgePending, + onUpdateTransaction, + status, + error, +}: StepProps) { + const { t } = useTranslation(); + + return ( + + + {error && } + + + {status.errors.fee && } + + ); +} + +export function StepAmountFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + const { t } = useTranslation(); + + invariant(account, "account required"); + + const { errors } = status; + const hasErrors = Object.keys(errors).length; + const canNext = !bridgePending && !hasErrors; + + return ( + <> + + + + + + + ); +} diff --git a/src/renderer/families/solana/DelegationWithdrawFlowModal/steps/StepConfirmation.js b/src/renderer/families/solana/DelegationWithdrawFlowModal/steps/StepConfirmation.js new file mode 100644 index 0000000000..22fa751620 --- /dev/null +++ b/src/renderer/families/solana/DelegationWithdrawFlowModal/steps/StepConfirmation.js @@ -0,0 +1,110 @@ +// @flow + +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import React from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; +import Button from "~/renderer/components/Button"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import RetryButton from "~/renderer/components/RetryButton"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; +import { OperationDetails } from "~/renderer/drawers/OperationDetails"; +import { setDrawer } from "~/renderer/drawers/Provider"; +import { multiline } from "~/renderer/styles/helpers"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import type { StepProps } from "../types"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; +`; + +function StepConfirmation({ + account, + t, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + if (optimisticOperation) { + return ( + + + + + } + description={multiline( + t("solana.delegation.withdraw.flow.steps.confirmation.success.text"), + )} + /> + + ); + } + + if (error) { + return ( + + + {signed ? ( + } /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + transitionTo, + account, + parentAccount, + onRetry, + error, + openModal, + onClose, + optimisticOperation, + transaction, +}: StepProps) { + return ( + + + {optimisticOperation ? ( + + ) : error ? ( + + ) : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/families/solana/DelegationWithdrawFlowModal/types.js b/src/renderer/families/solana/DelegationWithdrawFlowModal/types.js new file mode 100644 index 0000000000..8bfc6439e7 --- /dev/null +++ b/src/renderer/families/solana/DelegationWithdrawFlowModal/types.js @@ -0,0 +1,32 @@ +// @flow +import type { Transaction } from "@ledgerhq/live-common/lib/families/solana/types"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Account, Operation, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import type { TFunction } from "react-i18next"; +import type { Step } from "~/renderer/components/Stepper"; + +export type StepId = "amount" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: ?Account, + parentAccount: ?Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + optimisticOperation: Operation, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onUpdateTransaction: ((Transaction) => Transaction) => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/families/solana/shared/components/Ellipsis.js b/src/renderer/families/solana/shared/components/Ellipsis.js new file mode 100644 index 0000000000..ffc251122e --- /dev/null +++ b/src/renderer/families/solana/shared/components/Ellipsis.js @@ -0,0 +1,11 @@ +//@flow +import styled from "styled-components"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; + +export const Ellipsis: ThemedComponent<{}> = styled.div` + flex: 1; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/renderer/families/solana/shared/components/ErrorDisplay.js b/src/renderer/families/solana/shared/components/ErrorDisplay.js new file mode 100644 index 0000000000..ac1cb37767 --- /dev/null +++ b/src/renderer/families/solana/shared/components/ErrorDisplay.js @@ -0,0 +1,24 @@ +//@flow +import React from "react"; +import Box from "~/renderer/components/Box"; +import styled from "styled-components"; +import TranslatedError from "~/renderer/components/TranslatedError"; +import { ErrorContainer } from "~/renderer/components/Input"; + +const ErrorBox = styled(Box)` + color: ${p => p.theme.colors.pearl}; +`; + +type Props = { + error: Error, +}; + +export default function ErrorDisplay({ error }: Props) { + return ( + + + + + + ); +} diff --git a/src/renderer/families/solana/shared/components/LedgerByFigmentTCLink.js b/src/renderer/families/solana/shared/components/LedgerByFigmentTCLink.js new file mode 100644 index 0000000000..b339225446 --- /dev/null +++ b/src/renderer/families/solana/shared/components/LedgerByFigmentTCLink.js @@ -0,0 +1,18 @@ +//@flow +import React from "react"; +import LinkWithExternalIcon from "~/renderer/components/LinkWithExternalIcon"; +import { useTranslation } from "react-i18next"; +import { urls } from "~/config/urls"; +import { openURL } from "~/renderer/linking"; + +export default function LedgerByFigmentTC() { + const { t } = useTranslation(); + const openLedgerByFigmentTC = () => openURL(urls.solana.ledgerByFigmentTC); + + return ( + + ); +} diff --git a/src/renderer/families/solana/shared/components/ValidatorRow.js b/src/renderer/families/solana/shared/components/ValidatorRow.js new file mode 100644 index 0000000000..d6f8ad0139 --- /dev/null +++ b/src/renderer/families/solana/shared/components/ValidatorRow.js @@ -0,0 +1,97 @@ +//@flow +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import { getAddressExplorer, getDefaultExplorerView } from "@ledgerhq/live-common/lib/explorers"; +import type { ValidatorAppValidator } from "@ledgerhq/live-common/lib/families/solana/validator-app"; +import type { CryptoCurrency, Unit } from "@ledgerhq/live-common/lib/types"; +import { BigNumber } from "bignumber.js"; +import React, { useCallback } from "react"; +import { Trans } from "react-i18next"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import type { ValidatorRowProps } from "~/renderer/components/Delegation/ValidatorRow"; +import ValidatorRow, { IconContainer } from "~/renderer/components/Delegation/ValidatorRow"; +import FirstLetterIcon from "~/renderer/components/FirstLetterIcon"; +import Image from "~/renderer/components/Image"; +import Text from "~/renderer/components/Text"; +import Check from "~/renderer/icons/Check"; +import { openURL } from "~/renderer/linking"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; + +type Props = { + currency: CryptoCurrency, + validator: ValidatorAppValidator, + active?: boolean, + showStake?: boolean, + onClick?: (v: ValidatorAppValidator) => void, + unit: Unit, +}; + +function SolanaValidatorRow({ validator, active, showStake, onClick, unit, currency }: Props) { + const explorerView = getDefaultExplorerView(currency); + + const onExternalLink = useCallback(() => { + const url = validator.wwwUrl || getAddressExplorer(explorerView, validator.voteAccount); + + if (url) { + openURL(url); + } + }, [explorerView, validator]); + + return ( + + {validator.avatarUrl === undefined && } + {validator.avatarUrl !== undefined && ( + + )} + + } + title={validator.name || validator.voteAccount} + onExternalLink={onExternalLink} + unit={unit} + subtitle={ + showStake ? ( + <> + + + {formatCurrencyUnit(unit, new BigNumber(validator.activeStake), { + showCode: true, + })} + + + ) : null + } + sideInfo={ + + + + {`${validator.commission} %`} + + + + + + + + + + } + > + ); +} + +const StyledValidatorRow: ThemedComponent = styled(ValidatorRow)` + border-color: transparent; + margin-bottom: 0; +`; + +const ChosenMark: ThemedComponent<{ active: boolean }> = styled(Check).attrs(p => ({ + color: p.active ? p.theme.colors.palette.primary.main : "transparent", + size: 14, +}))``; + +export default SolanaValidatorRow; diff --git a/src/renderer/families/solana/shared/fields/ValidatorsField.js b/src/renderer/families/solana/shared/fields/ValidatorsField.js new file mode 100644 index 0000000000..31007596cb --- /dev/null +++ b/src/renderer/families/solana/shared/fields/ValidatorsField.js @@ -0,0 +1,133 @@ +// @flow +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { getAddressExplorer, getDefaultExplorerView } from "@ledgerhq/live-common/lib/explorers"; +import { useLedgerFirstShuffledValidators } from "@ledgerhq/live-common/lib/families/solana/react"; +import { swap } from "@ledgerhq/live-common/lib/families/solana/utils"; +import type { ValidatorAppValidator } from "@ledgerhq/live-common/lib/families/solana/validator-app"; +import type { Account, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import invariant from "invariant"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { TFunction } from "react-i18next"; +import { Trans } from "react-i18next"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import { NoResultPlaceholder } from "~/renderer/components/Delegation/ValidatorSearchInput"; +import ScrollLoadingList from "~/renderer/components/ScrollLoadingList"; +import Text from "~/renderer/components/Text"; +import IconAngleDown from "~/renderer/icons/AngleDown"; +import { openURL } from "~/renderer/linking"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import ValidatorRow from "../components/ValidatorRow"; + +type Props = { + t: TFunction, + account: Account, + status: TransactionStatus, + chosenVoteAccAddr: ?string, + onChangeValidator: (v: ValidatorAppValidator) => void, +}; + +const ValidatorField = ({ t, account, onChangeValidator, chosenVoteAccAddr, status }: Props) => { + if (!status) return null; + + invariant(account && account.solanaResources, "solana account and resources required"); + + const { solanaResources } = account; + + const [search, setSearch] = useState(""); + const [showAll, setShowAll] = useState(false); + + const unit = getAccountUnit(account); + + const validators = useLedgerFirstShuffledValidators(account.currency); + + const chosenValidator = useMemo(() => { + if (chosenVoteAccAddr !== null) { + return validators.find(v => v.voteAccount === chosenVoteAccAddr); + } + }, [validators, chosenVoteAccAddr]); + + const validatorsFiltered = useMemo(() => { + return validators.filter(validator => { + return ( + validator.name?.toLowerCase().includes(search) || + validator.voteAccount.toLowerCase().includes(search) + ); + }); + }, [validators, search]); + + const containerRef = useRef(); + + const onSearch = (event: SyntheticInputEvent) => setSearch(event.target.value); + + /** auto focus first input on mount */ + useEffect(() => { + /** $FlowFixMe */ + if (containerRef && containerRef.current && containerRef.current.querySelector) { + const firstInput = containerRef.current.querySelector("input"); + if (firstInput && firstInput.focus) firstInput.focus(); + } + }, []); + + const renderItem = (validator: ValidatorAppValidator, validatorIdx: number) => { + return ( + + ); + }; + + return ( + + + 0 && + } + /> + + setShowAll(shown => !shown)}> + + + + + + + ); +}; + +const ValidatorsFieldContainer: ThemedComponent<{}> = styled(Box)` + border: 1px solid ${p => p.theme.colors.palette.divider}; + border-radius: 4px; +`; + +const SeeAllButton: ThemedComponent<{ expanded: boolean }> = styled.div` + display: flex; + color: ${p => p.theme.colors.wallet}; + align-items: center; + justify-content: center; + border-top: 1px solid ${p => p.theme.colors.palette.divider}; + height: 40px; + cursor: pointer; + + &:hover ${Text} { + text-decoration: underline; + } + + > :nth-child(2) { + margin-left: 8px; + transform: rotate(${p => (p.expanded ? "180deg" : "0deg")}); + } +`; + +export default ValidatorField; diff --git a/src/renderer/modals/index.js b/src/renderer/modals/index.js index 7d1c892f9e..4346efdb49 100644 --- a/src/renderer/modals/index.js +++ b/src/renderer/modals/index.js @@ -61,6 +61,13 @@ import MODAL_POLKADOT_BOND from "../families/polkadot/BondFlowModal"; import MODAL_POLKADOT_UNBOND from "../families/polkadot/UnbondFlowModal"; import MODAL_POLKADOT_REBOND from "../families/polkadot/RebondFlowModal"; +import MODAL_SOLANA_REWARDS_INFO from "../families/solana/DelegationFlowModal/Info"; +import MODAL_SOLANA_DELEGATE from "../families/solana/DelegationFlowModal"; +import MODAL_SOLANA_DELEGATION_ACTIVATE from "../families/solana/DelegationActivateFlowModal"; +import MODAL_SOLANA_DELEGATION_DEACTIVATE from "../families/solana/DelegationDeactivateFlowModal"; +import MODAL_SOLANA_DELEGATION_REACTIVATE from "../families/solana/DelegationReactivateFlowModal"; +import MODAL_SOLANA_DELEGATION_WITHDRAW from "../families/solana/DelegationWithdrawFlowModal"; + // Lending import MODAL_LEND_MANAGE from "../screens/lend/modals/ManageLend"; import MODAL_LEND_ENABLE_INFO from "../screens/lend/modals/EnableInfoModal"; @@ -120,6 +127,12 @@ const modals: { [_: string]: React$ComponentType } = { MODAL_POLKADOT_BOND, MODAL_POLKADOT_UNBOND, MODAL_POLKADOT_REBOND, + MODAL_SOLANA_REWARDS_INFO, + MODAL_SOLANA_DELEGATE, + MODAL_SOLANA_DELEGATION_ACTIVATE, + MODAL_SOLANA_DELEGATION_DEACTIVATE, + MODAL_SOLANA_DELEGATION_REACTIVATE, + MODAL_SOLANA_DELEGATION_WITHDRAW, MODAL_FULL_NODE, MODAL_LOTTIE_DEBUGGER, MODAL_RECOVERY_SEED_WARNING, diff --git a/static/i18n/en/app.json b/static/i18n/en/app.json index 9aa278fbb0..0db7028c77 100644 --- a/static/i18n/en/app.json +++ b/static/i18n/en/app.json @@ -2558,6 +2558,115 @@ } } }, + "solana": { + "common": { + "broadcastError": "Your transaction may have failed. Please wait a moment then check the transaction history before trying again.", + "viewDetails": "View details", + "connectDevice": { + "title": "Device" + }, + "confirmation": { + "title": "Confirmation" + } + }, + "delegation": { + "totalStake": "Total Stake", + "commission": "Commission", + "delegate": "Add", + "listHeader": "Delegations", + "availableBalance": "Available balance", + "active": "Active", + "activeTooltip": "Active amount generate rewards", + "inactiveTooltip": "Inactive amount does not generate rewards", + "delegatedInfoTooltip": "Total amount that is delegated to validators.", + "withdrawableInfoTooltip": "Total amount that is withdrawable from delegations.", + "withdrawableTitle": "Withdrawable", + "statusUpdateNotice": "The status of the delegation will be updated when the transaction is confirmed.", + "ledgerByFigmentTC": "Ledger by Figment T&Cs", + "emptyState": { + "description": "You can earn SOL rewards by delegating your assets.", + "info": "How Delegation works", + "delegation": "Earn rewards" + }, + "earnRewards": { + "description": "You may earn rewards by delegating your SOL assets to a validator.", + "bullet": { + "0": "You keep ownership of delegated assets", + "1": "Delegate using your Ledger device", + "2": "Assets will be available after undelegation" + }, + "warning": "Choose your validator wisely: Part of your delegated assets may be irrevocably lost if the validator does not behave appropriately." + }, + "flow": { + "title": "Delegate", + "steps": { + "validator": { + "title": "Validator" + }, + "amount": { + "title": "Amount" + }, + "confirmation": { + "success": { + "title": "You have successfully delegated your assets" + } + } + } + }, + "withdraw": { + "flow": { + "title": "Withdraw", + "steps": { + "amount": { + "title": "Amount" + }, + "confirmation": { + "success": { + "title": "You have successfully withdrawn your assets.", + "text": "Your account balance will be updated when the transaction is confirmed." + } + } + } + } + }, + "activate": { + "flow": { + "title": "Activate", + "steps": { + "confirmation": { + "success": { + "title": "You have successfully activated you delegation." + } + } + } + } + }, + "reactivate": { + "flow": { + "title": "Reactivate", + "steps": { + "confirmation": { + "success": { + "title": "You have successfully reactivated you delegation." + } + } + } + } + }, + "deactivate": { + "flow": { + "title": "Deactivate", + "steps": { + "confirmation": { + "success": { + "title": "You have successfully deactivated your delegation." + } + } + } + } + } + } + }, "delegation": { "title": "Earn rewards", "header": "Delegation",