diff --git a/package.json b/package.json index f72c8b1c5..7b64dcfac 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@cosmjs/tendermint-rpc": "^0.32.1", "@dydxprotocol/v4-abacus": "^1.7.87", "@dydxprotocol/v4-client-js": "^1.1.23", - "@dydxprotocol/v4-localization": "^1.1.127", + "@dydxprotocol/v4-localization": "^1.1.128", "@ethersproject/providers": "^5.7.2", "@hugocxl/react-to-image": "^0.0.9", "@js-joda/core": "^5.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33f6cbca6..129555f0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ dependencies: specifier: ^1.1.23 version: 1.1.23 '@dydxprotocol/v4-localization': - specifier: ^1.1.127 - version: 1.1.127 + specifier: ^1.1.128 + version: 1.1.128 '@ethersproject/providers': specifier: ^5.7.2 version: 5.7.2 @@ -1429,8 +1429,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@1.1.127: - resolution: {integrity: sha512-6aN+pRLrOqbhZFHGcmfhBxC/S8mui/0zl2jt61Z8lP4BF8P9jLr7W0EF9UTCVydHJVUbjxJui8c1ApWtOBDZtQ==} + /@dydxprotocol/v4-localization@1.1.128: + resolution: {integrity: sha512-jVGDTQUXWkx325Pd0Kca2z7vLZy61Q0gG7oQuNdJ6TB15zMRU74XMQvDZgJvCeM2WmiIqdyR5HnRJwL/gdnzQA==} dev: false /@dydxprotocol/v4-proto@5.0.0-dev.0: diff --git a/src/components/Details.tsx b/src/components/Details.tsx index 52b3fcf71..32629fecd 100644 --- a/src/components/Details.tsx +++ b/src/components/Details.tsx @@ -153,8 +153,6 @@ const detailsLayoutVariants = { const itemLayoutVariants = { column: css` - isolation: isolate; - ${layoutMixins.scrollArea} ${layoutMixins.stickyArea0} diff --git a/src/components/DropdownIcon.tsx b/src/components/DropdownIcon.tsx new file mode 100644 index 000000000..4665e603d --- /dev/null +++ b/src/components/DropdownIcon.tsx @@ -0,0 +1,36 @@ +import styled, { css } from 'styled-components'; + +import { Icon, IconName } from '@/components/Icon'; + +type ElementProps = { + iconName?: IconName; + isOpen?: boolean; +}; + +type StyleProps = { + className?: string; +}; + +export const DropdownIcon = ({ + iconName = IconName.Triangle, + isOpen, + className, +}: ElementProps & StyleProps) => { + return ( + <$DropdownIcon aria-hidden="true" isOpen={isOpen} className={className}> + + $DropdownIcon> + ); +}; + +const $DropdownIcon = styled.span<{ isOpen?: boolean }>` + display: inline-flex; + transition: transform 0.3s var(--ease-out-expo); + font-size: 0.375em; + + ${({ isOpen }) => + isOpen && + css` + transform: scaleY(-1); + `} +`; diff --git a/src/components/FormMaxInputToggleButton.tsx b/src/components/FormMaxInputToggleButton.tsx index fd421b27c..a89b34eb9 100644 --- a/src/components/FormMaxInputToggleButton.tsx +++ b/src/components/FormMaxInputToggleButton.tsx @@ -18,7 +18,7 @@ type ElementProps = { }; export const FormMaxInputToggleButton = ({ - size = ButtonSize.XSmall, + size = ButtonSize.Small, isInputEmpty, isLoading, onPressedChange, @@ -42,4 +42,6 @@ const $FormMaxInputToggleButton = styled(ToggleButton)` ${formMixins.inputInnerToggleButton} --button-padding: 0 0.5rem; + --button-backgroundColor: var(--color-accent); + --button-textColor: var(--color-text-2); `; diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index eeea7c428..d7f0f4cc7 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -25,6 +25,7 @@ type StyleProps = { className?: string; fullWidth?: boolean; noBlur?: boolean; + align?: 'start' | 'center' | 'end'; sideOffset?: number; triggerType?: TriggerType; withPortal?: boolean; @@ -38,6 +39,7 @@ export const Popover = ({ onOpenChange, slotTrigger, slotAnchor, + align = 'center', sideOffset, fullWidth, noBlur, @@ -59,6 +61,7 @@ export const Popover = ({ $noBlur={noBlur} className={className} sideOffset={sideOffset} + align={align} > {children} $Content> diff --git a/src/components/SearchSelectMenu.tsx b/src/components/SearchSelectMenu.tsx index b803bd467..8833ffbcd 100644 --- a/src/components/SearchSelectMenu.tsx +++ b/src/components/SearchSelectMenu.tsx @@ -1,6 +1,6 @@ import { useRef, useState, type ReactNode } from 'react'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { type MenuConfig } from '@/constants/menus'; @@ -12,13 +12,14 @@ import { layoutMixins } from '@/styles/layoutMixins'; import { ComboboxMenu } from '@/components/ComboboxMenu'; import { type DetailsItem } from '@/components/Details'; -import { Icon, IconName } from '@/components/Icon'; import { Popover, TriggerType } from '@/components/Popover'; import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; import { WithLabel } from '@/components/WithLabel'; import { getSimpleStyledOutputType } from '@/lib/genericFunctionalComponentUtils'; +import { DropdownIcon } from './DropdownIcon'; + type ElementProps = { asChild?: boolean; children: ReactNode; @@ -58,7 +59,7 @@ export const SearchSelectMenu = ({ ) : ( <$MenuTrigger> {label ? <$WithLabel label={label}>{children}$WithLabel> : children} - <$TriggerIcon iconName={IconName.Triangle} open={open} /> + $MenuTrigger> ); @@ -153,15 +154,3 @@ const $ComboboxMenu = styled(ComboboxMenu)` max-height: 30vh; overflow: auto; ` as typeof ComboboxMenuStyleType; - -const $TriggerIcon = styled(Icon)<{ open?: boolean }>` - width: 0.625rem; - height: 0.375rem; - color: var(--color-text-0); - - ${({ open }) => - open && - css` - transform: rotate(180deg); - `} -`; diff --git a/src/components/Table.tsx b/src/components/Table.tsx index ca99b39da..dbed2d579 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -566,8 +566,8 @@ const TableColumnHeader = ({ aria-hidden="true" sortDirection={ state.sortDescriptor?.column === column.key - ? state.sortDescriptor?.direction - : undefined + ? state.sortDescriptor?.direction ?? 'none' + : 'none' } > @@ -857,7 +857,7 @@ const $Td = styled.td` } `; -const $SortArrow = styled.span<{ sortDirection?: 'ascending' | 'descending' }>` +const $SortArrow = styled.span<{ sortDirection: 'ascending' | 'descending' | 'none' }>` float: right; margin-left: auto; @@ -868,13 +868,18 @@ const $SortArrow = styled.span<{ sortDirection?: 'ascending' | 'descending' }>` font-size: 0.375em; - ${$Th}[aria-sort="none"] & { - visibility: hidden; - } - - ${$Th}[aria-sort="ascending"] & { - transform: scaleY(-1); - } + ${({ sortDirection }) => + ({ + ascending: css` + transform: scaleY(-1); + `, + descending: css` + transform: scaleY(1); + `, + none: css` + visibility: hidden; + `, + })[sortDirection]} `; const $Thead = styled.thead` diff --git a/src/components/ValidatorDropdown.tsx b/src/components/ValidatorDropdown.tsx new file mode 100644 index 000000000..d0e763544 --- /dev/null +++ b/src/components/ValidatorDropdown.tsx @@ -0,0 +1,230 @@ +import { Dispatch, Key, SetStateAction, memo, useCallback, useMemo, useState } from 'react'; + +import { Validator } from '@dydxprotocol/v4-client-js/build/node_modules/@dydxprotocol/v4-proto/src/codegen/cosmos/staking/v1beta1/staking'; +import styled from 'styled-components'; +import { formatUnits } from 'viem'; + +import { STRING_KEYS } from '@/constants/localization'; +import { ValidatorData } from '@/constants/validators'; + +import { useStakingValidator } from '@/hooks/useStakingValidator'; +import { useStringGetter } from '@/hooks/useStringGetter'; +import { useTokenConfigs } from '@/hooks/useTokenConfigs'; + +import { layoutMixins } from '@/styles/layoutMixins'; +import { popoverMixins } from '@/styles/popoverMixins'; + +import { DropdownIcon } from '@/components/DropdownIcon'; +import { Icon, IconName } from '@/components/Icon'; +import { Link } from '@/components/Link'; +import { Output, OutputType } from '@/components/Output'; +import { Popover, TriggerType } from '@/components/Popover'; +import { ColumnDef, Table } from '@/components/Table'; +import { TableCell } from '@/components/Table/TableCell'; +import { Tag } from '@/components/Tag'; +import { ValidatorFaviconIcon } from '@/components/ValidatorName'; + +import { MustBigNumber } from '@/lib/numbers'; + +const ValidatorsDropdownContent = ({ + availableValidators, + setSelectedValidator, + setIsPopoverOpen, +}: { + availableValidators: Validator[]; + setSelectedValidator: Dispatch>; + setIsPopoverOpen: (isOpen: boolean) => void; +}) => { + const stringGetter = useStringGetter(); + const { chainTokenLabel } = useTokenConfigs(); + + const votingPowerDecimals = 36; // hardcoded solution; fix in OTE-390 + const commissionRateDecimals = 18; // hardcoded solution; fix in OTE-390 + + const columns = useMemo( + () => + [ + { + columnKey: 'commission', + getCellValue: (row) => row.commissionRate.toNumber(), + label: stringGetter({ key: STRING_KEYS.VALIDATOR }), + allowsSorting: true, + renderCell: ({ website, name, commissionRate }) => ( + } + > + {name} + + {stringGetter({ + key: STRING_KEYS.COMMISSION_PERCENTAGE, + params: { + PERCENTAGE: ( + <$CommissionOutput type={OutputType.Percent} value={commissionRate} /> + ), + }, + })} + + + ), + }, + { + columnKey: 'votingPower', + getCellValue: (row) => row.votingPower.toNumber(), + allowsSorting: true, + label: stringGetter({ key: STRING_KEYS.VOTING_POWER }), + tag: {chainTokenLabel} , + renderCell: ({ votingPower }) => , + }, + ] satisfies ColumnDef[], + [stringGetter, chainTokenLabel] + ); + + const filteredValidators = availableValidators.reduce((validators: ValidatorData[], val) => { + if (val.description) { + validators.push({ + name: val.description.moniker, + operatorAddress: val.operatorAddress, + votingPower: MustBigNumber(formatUnits(BigInt(val.delegatorShares), votingPowerDecimals)), + commissionRate: MustBigNumber( + formatUnits(BigInt(val.commission?.commissionRates?.rate ?? 0), commissionRateDecimals) + ), + website: val.description?.website, + }); + } + return validators; + }, []); + + const onRowAction = useCallback( + (key: Key) => { + const newValidator = availableValidators.find((v) => v.operatorAddress === key); + if (newValidator) { + setSelectedValidator(newValidator); + } + setIsPopoverOpen(false); + }, + [setSelectedValidator, setIsPopoverOpen, availableValidators] + ); + + return ( + <$ScrollArea> + <$Table + key="validators" + label="Validators" + data={filteredValidators} + getRowKey={(row: ValidatorData) => row.operatorAddress} + onRowAction={onRowAction} + columns={columns} + defaultSortDescriptor={{ + column: 'commission', + direction: 'ascending', + }} + paginationBehavior="showAll" + slotEmpty={ + <> + + + { + 'There are no validators currently available.' // TODO: localize + } + + > + } + /> + $ScrollArea> + ); +}; + +type ElementProps = { + selectedValidator: Validator | undefined; + setSelectedValidator: Dispatch>; +}; + +export const ValidatorDropdown = memo( + ({ selectedValidator, setSelectedValidator }: ElementProps) => { + const { availableValidators } = useStakingValidator() ?? {}; + + const [isOpen, setIsOpen] = useState(false); + + const output = ( + <$Output + type={OutputType.Text} + value={selectedValidator?.description?.moniker} + slotLeft={ + + } + /> + ); + + const slotTrigger = selectedValidator?.description?.website ? ( + <$Link href={selectedValidator?.description?.website} withIcon> + {output} + $Link> + ) : ( + output + ); + + return ( + <$Popover + open={isOpen} + onOpenChange={setIsOpen} + slotTrigger={ + <$Trigger> + {slotTrigger} + <$DropdownIcon iconName={IconName.Caret} isOpen={isOpen} /> + $Trigger> + } + triggerType={TriggerType.MarketDropdown} + align="end" + sideOffset={8} + withPortal + > + + $Popover> + ); + } +); + +const $ScrollArea = styled.div` + ${layoutMixins.scrollArea} + + max-height: 20rem; +`; + +const $DropdownIcon = styled(DropdownIcon)` + margin-left: 0.5rem; +`; + +const $Popover = styled(Popover)` + ${popoverMixins.popover} +`; + +const $Table = styled(Table)` + --tableRow-backgroundColor: var(--color-layer-4); + --tableCell-padding: 0.5rem 1rem; +` as typeof Table; + +const $Trigger = styled.span` + display: flex; + align-items: center; +`; + +const $Output = styled(Output)` + color: var(--color-text-1); +`; + +const $Link = styled(Link)` + color: var(--color-text-1); +`; + +const $CommissionOutput = styled(Output)` + display: inline; +`; diff --git a/src/components/ValidatorName.tsx b/src/components/ValidatorName.tsx index a3bbdcb5e..2238be8d8 100644 --- a/src/components/ValidatorName.tsx +++ b/src/components/ValidatorName.tsx @@ -6,10 +6,6 @@ import styled from 'styled-components'; import { Link } from './Link'; import { Output, OutputType } from './Output'; -export type ValidatorNameProps = { - validator?: Validator; -}; - export const ValidatorFaviconIcon = ({ className, url, @@ -40,10 +36,7 @@ export const ValidatorFaviconIcon = ({ return null; }; -export const ValidatorName = ({ validator }: ValidatorNameProps) => { - if (!validator) { - return null; - } +export const ValidatorName = ({ validator }: { validator?: Validator }) => { const output = ( <$Output type={OutputType.Text} @@ -57,16 +50,23 @@ export const ValidatorName = ({ validator }: ValidatorNameProps) => { /> ); - if (validator?.description?.website) { - return ( - - {output} - - ); - } - return output; + return validator?.description?.website ? ( + + {output} + + ) : ( + output + ); }; +const $Img = styled.img` + width: 1.5em; + height: 1.5em; + border-radius: 50%; + object-fit: cover; + margin-right: 0.25em; +`; + const $IconContainer = styled.div` display: flex; align-items: center; @@ -80,14 +80,6 @@ const $IconContainer = styled.div` margin-right: 0.25em; `; -const $Img = styled.img` - width: 1.5em; - height: 1.5em; - border-radius: 50%; - object-fit: cover; - margin-right: 0.25em; -`; - const $Output = styled(Output)` color: var(--color-text-1); `; diff --git a/src/constants/validators.ts b/src/constants/validators.ts new file mode 100644 index 000000000..7b06ebad3 --- /dev/null +++ b/src/constants/validators.ts @@ -0,0 +1,9 @@ +import BigNumber from 'bignumber.js'; + +export type ValidatorData = { + name: string; + operatorAddress: string; + votingPower: BigNumber; + commissionRate: BigNumber; + website?: string; +}; diff --git a/src/hooks/useStakingValidator.ts b/src/hooks/useStakingValidator.ts index 66e4f0bf9..395209312 100644 --- a/src/hooks/useStakingValidator.ts +++ b/src/hooks/useStakingValidator.ts @@ -1,3 +1,9 @@ +import { useState } from 'react'; + +import { + BondStatus, + Validator, +} from '@dydxprotocol/v4-client-js/build/node_modules/@dydxprotocol/v4-proto/src/codegen/cosmos/staking/v1beta1/staking'; import { useQuery } from '@tanstack/react-query'; import { groupBy } from 'lodash'; import { shallowEqual } from 'react-redux'; @@ -8,6 +14,7 @@ import { getStakingDelegations, getUnbondingDelegations } from '@/state/accountS import { getSelectedNetwork } from '@/state/appSelectors'; import { useAppSelector } from '@/state/appTypes'; +import { MustBigNumber } from '../lib/numbers'; import { useDydxClient } from './useDydxClient'; export const useStakingValidator = () => { @@ -22,6 +29,9 @@ export const useStakingValidator = () => { }; } ); + + const [selectedValidator, setSelectedValidator] = useState(); + const validatorWhitelist = ENVIRONMENT_CONFIG_MAP[selectedNetwork].stakingValidators?.map( (delegation) => { return delegation.toLowerCase(); @@ -42,9 +52,53 @@ export const useStakingValidator = () => { const response = await getValidators(); - const filteredValidators = response?.validators.filter((validator) => + // Filter out jailed and unbonded validators + const availableValidators = + response?.validators.filter( + (validator: Validator) => + validator.status === BondStatus.BOND_STATUS_BONDED && validator.jailed === false + ) ?? []; + + // Sort validators 1/ in ascending commission and 2/ by descending stake weight + const sortByCommission = (validatorA: Validator, validatorB: Validator): number => { + return MustBigNumber(validatorA.commission?.commissionRates?.rate ?? 0).gt( + MustBigNumber(validatorB.commission?.commissionRates?.rate ?? 0) + ) + ? 1 + : -1; + }; + + const sortByCommissionAndStakeWeight = ( + validatorA: Validator, + validatorB: Validator + ): number => { + if ( + (validatorA.commission?.commissionRates?.rate ?? 0) === + (validatorB.commission?.commissionRates?.rate ?? 0) + ) { + return MustBigNumber(validatorA.delegatorShares).gt( + MustBigNumber(validatorB.delegatorShares) + ) + ? -1 + : 1; + } + return 0; + }; + + availableValidators.sort(sortByCommission); + availableValidators.sort(sortByCommissionAndStakeWeight); + + // Set the default validator to be the validator with the fewest tokens, selected from validators configured in the whitelist + const whitelistedValidators = response?.validators.filter((validator) => validatorOptions.includes(validator.operatorAddress.toLowerCase()) ); + + const validatorWithFewestTokens = (whitelistedValidators ?? availableValidators ?? []).reduce( + (prev, curr) => { + return BigInt(curr.tokens) < BigInt(prev.tokens) ? curr : prev; + } + ); + const stakingValidators = response?.validators.filter((validator) => currentDelegations?.some((d) => d.validator === validator.operatorAddress.toLowerCase()) @@ -55,30 +109,29 @@ export const useStakingValidator = () => { unbondingDelegations?.some((d) => d.validator === validator.operatorAddress.toLowerCase()) ) ?? []; - if (!filteredValidators || filteredValidators.length === 0) { - return undefined; - } - - // Find the validator with the fewest tokens - const validatorWithFewestTokens = filteredValidators.reduce((prev, curr) => { - return BigInt(curr.tokens) < BigInt(prev.tokens) ? curr : prev; - }); - return { - selectedValidator: validatorWithFewestTokens, + validatorWithFewestTokens, + availableValidators, stakingValidators: groupBy(stakingValidators, ({ operatorAddress }) => operatorAddress), unbondingValidators: groupBy(unbondingValidators, ({ operatorAddress }) => operatorAddress), - currentDelegations, }; }; const { data } = useQuery({ queryKey: ['stakingValidator', selectedNetwork, currentDelegations, unbondingDelegations], queryFn, - enabled: Boolean(isCompositeClientConnected && validatorWhitelist?.length > 0), + enabled: Boolean(isCompositeClientConnected), refetchOnWindowFocus: false, refetchOnReconnect: false, }); - return data; + return { + selectedValidator, + setSelectedValidator, + defaultValidator: data?.validatorWithFewestTokens, + availableValidators: data?.availableValidators, + stakingValidators: data?.stakingValidators, + unbondingValidators: data?.unbondingValidators, + currentDelegations, + }; }; diff --git a/src/pages/token/rewards/StakingPanel.tsx b/src/pages/token/rewards/StakingPanel.tsx index 79b912a8b..a90b74a53 100644 --- a/src/pages/token/rewards/StakingPanel.tsx +++ b/src/pages/token/rewards/StakingPanel.tsx @@ -8,7 +8,6 @@ import { STRING_KEYS } from '@/constants/localization'; import { useAccountBalance } from '@/hooks/useAccountBalance'; import { useComplianceState } from '@/hooks/useComplianceState'; -import { useStakingValidator } from '@/hooks/useStakingValidator'; import { useStringGetter } from '@/hooks/useStringGetter'; import { useTokenConfigs } from '@/hooks/useTokenConfigs'; @@ -36,7 +35,6 @@ export const StakingPanel = ({ className }: { className?: string }) => { const { complianceState } = useComplianceState(); const { nativeTokenBalance, nativeStakingBalance } = useAccountBalance(); const { chainTokenLabel } = useTokenConfigs(); - const { selectedValidator } = useStakingValidator() ?? {}; const unstakedApr = 16.94; /* OTE-406: Hardcoded for now until I get the APY endpoint working */ const stakedApr = 16.94; /* OTE-406: Hardcoded for now until I get the APY endpoint working */ @@ -82,7 +80,7 @@ export const StakingPanel = ({ className }: { className?: string }) => { $label> <$BalanceOutput type={OutputType.Asset} value={nativeTokenBalance} /> - {canAccountTrade && selectedValidator && ( + {canAccountTrade && ( - <$Button isOpen={isOpen} onClick={() => setIsOpen(!isOpen)}> + <$Button onClick={() => setIsOpen(!isOpen)}> {stringGetter({ key: STRING_KEYS.UNOPENED_ISOLATED_POSITIONS })} - + $Button> {isOpen && ( @@ -105,7 +106,7 @@ const $UnopenedIsolatedPositionsDrawerContainer = styled.div<{ isOpen?: boolean border-top: var(--border); ${({ isOpen }) => isOpen && 'height: 100%;'} `; -const $Button = styled(Button)<{ isOpen?: boolean }>` +const $Button = styled(Button)` position: sticky; top: 0; gap: 1rem; @@ -113,14 +114,6 @@ const $Button = styled(Button)<{ isOpen?: boolean }>` background-color: transparent; border: none; margin: 0 1rem; - - ${({ isOpen }) => - isOpen && - ` - svg { - transform: rotate(180deg); - } - `} `; const $Cards = styled.div` ${layoutMixins.flexWrap} diff --git a/src/views/MarketsDropdown.tsx b/src/views/MarketsDropdown.tsx index ca2620ed8..32845f864 100644 --- a/src/views/MarketsDropdown.tsx +++ b/src/views/MarketsDropdown.tsx @@ -17,7 +17,7 @@ import { popoverMixins } from '@/styles/popoverMixins'; import { AssetIcon } from '@/components/AssetIcon'; import { Button } from '@/components/Button'; -import { Icon, IconName } from '@/components/Icon'; +import { DropdownIcon } from '@/components/DropdownIcon'; import { Output, OutputType } from '@/components/Output'; import { Popover, TriggerType } from '@/components/Popover'; import { ColumnDef, Table } from '@/components/Table'; @@ -198,9 +198,7 @@ export const MarketsDropdown = memo( )} {stringGetter({ key: isOpen ? STRING_KEYS.TAP_TO_CLOSE : STRING_KEYS.ALL_MARKETS })} - <$DropdownIcon aria-hidden="true"> - - $DropdownIcon> + $TriggerContainer> } @@ -277,15 +275,6 @@ const $TriggerContainer = styled.div<{ $isOpen: boolean }>` } `; -const $DropdownIcon = styled.span` - margin-left: auto; - - display: inline-flex; - transition: transform 0.3s var(--ease-out-expo); - - font-size: 0.375rem; -`; - const $Popover = styled(Popover)` ${popoverMixins.popover} --popover-item-height: 3.375rem; diff --git a/src/views/StakeRewardButtonAndReceipt.tsx b/src/views/StakeRewardButtonAndReceipt.tsx index dbdbbc801..3bc546da4 100644 --- a/src/views/StakeRewardButtonAndReceipt.tsx +++ b/src/views/StakeRewardButtonAndReceipt.tsx @@ -167,6 +167,8 @@ const $AlertMessage = styled(AlertMessage)` const $WithDetailsReceipt = styled(WithDetailsReceipt)<{ isForm: boolean }>` --withReceipt-backgroundColor: var(--color-layer-2); + + color: var(--color-text-form); width: 100%; ${({ isForm }) => diff --git a/src/views/dialogs/UnstakeDialog.tsx b/src/views/dialogs/UnstakeDialog.tsx index bb36d1e67..f91bad952 100644 --- a/src/views/dialogs/UnstakeDialog.tsx +++ b/src/views/dialogs/UnstakeDialog.tsx @@ -3,10 +3,11 @@ import styled from 'styled-components'; import { STRING_KEYS } from '@/constants/localization'; import { useStringGetter } from '@/hooks/useStringGetter'; +import { useTokenConfigs } from '@/hooks/useTokenConfigs'; +import { AssetIcon } from '@/components/AssetIcon'; import { Dialog } from '@/components/Dialog'; - -import { UnstakeForm } from '../forms/UnstakeForm'; +import { UnstakeForm } from '@/views/forms/UnstakeForm'; type ElementProps = { setIsOpen?: (open: boolean) => void; @@ -15,8 +16,15 @@ type ElementProps = { export const UnstakeDialog = ({ setIsOpen }: ElementProps) => { const stringGetter = useStringGetter(); + const { chainTokenLabel } = useTokenConfigs(); + return ( - <$Dialog isOpen setIsOpen={setIsOpen} title={stringGetter({ key: STRING_KEYS.UNSTAKE })}> + <$Dialog + isOpen + setIsOpen={setIsOpen} + slotIcon={} + title={stringGetter({ key: STRING_KEYS.UNSTAKE })} + > setIsOpen?.(false)} /> $Dialog> ); diff --git a/src/views/forms/StakeForm/StakeButtonAndReceipt.tsx b/src/views/forms/StakeForm/StakeButtonAndReceipt.tsx index 16d155319..df7b9d718 100644 --- a/src/views/forms/StakeForm/StakeButtonAndReceipt.tsx +++ b/src/views/forms/StakeForm/StakeButtonAndReceipt.tsx @@ -1,17 +1,22 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { Validator } from '@dydxprotocol/v4-client-js/build/node_modules/@dydxprotocol/v4-proto/src/codegen/cosmos/staking/v1beta1/staking'; import { SelectedGasDenom } from '@dydxprotocol/v4-client-js/src/clients/constants'; +import styled from 'styled-components'; import { STRING_KEYS } from '@/constants/localization'; import { NumberSign } from '@/constants/numbers'; import { useAccountBalance } from '@/hooks/useAccountBalance'; -import { useStakingValidator } from '@/hooks/useStakingValidator'; import { useStringGetter } from '@/hooks/useStringGetter'; import { useTokenConfigs } from '@/hooks/useTokenConfigs'; +import { useURLConfigs } from '@/hooks/useURLConfigs'; import { DiffOutput } from '@/components/DiffOutput'; +import { Link } from '@/components/Link'; import { Output, OutputType } from '@/components/Output'; import { Tag } from '@/components/Tag'; -import { ValidatorName } from '@/components/ValidatorName'; +import { ValidatorDropdown } from '@/components/ValidatorDropdown'; import { WithTooltip } from '@/components/WithTooltip'; import { StakeButtonAlert, StakeRewardButtonAndReceipt } from '@/views/StakeRewardButtonAndReceipt'; @@ -22,13 +27,22 @@ type ElementProps = { fee?: BigNumberish; amount?: number; isLoading: boolean; + selectedValidator: Validator | undefined; + setSelectedValidator: Dispatch>; }; -export const StakeButtonAndReceipt = ({ error, fee, amount, isLoading }: ElementProps) => { +export const StakeButtonAndReceipt = ({ + error, + fee, + amount, + isLoading, + selectedValidator, + setSelectedValidator, +}: ElementProps) => { const stringGetter = useStringGetter(); const { chainTokenLabel } = useTokenConfigs(); + const { mintscanValidatorsLearnMore } = useURLConfigs(); const { nativeStakingBalance } = useAccountBalance(); - const { selectedValidator } = useStakingValidator() ?? {}; const newStakedBalance = amount ? MustBigNumber(nativeStakingBalance).plus(amount) : undefined; @@ -36,11 +50,29 @@ export const StakeButtonAndReceipt = ({ error, fee, amount, isLoading }: Element { key: 'validator', label: ( - - {stringGetter({ key: STRING_KEYS.SELECTED_VALIDATOR })} + + {stringGetter({ key: STRING_KEYS.MINTSCAN })} + $Link> + ), + }, + })} + > + {stringGetter({ + key: STRING_KEYS.VALIDATOR, + })} ), - value: , + value: ( + + ), }, { key: 'fees', @@ -86,3 +118,8 @@ export const StakeButtonAndReceipt = ({ error, fee, amount, isLoading }: Element /> ); }; + +const $Link = styled(Link)` + display: inline; + text-decoration: underline; +`; diff --git a/src/views/forms/StakeForm/index.tsx b/src/views/forms/StakeForm/index.tsx index ba22f912a..439ac74d7 100644 --- a/src/views/forms/StakeForm/index.tsx +++ b/src/views/forms/StakeForm/index.tsx @@ -47,7 +47,7 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { const { delegate, getDelegateFee } = useSubaccount(); const { nativeTokenBalance: balance } = useAccountBalance(); - const { selectedValidator } = useStakingValidator() ?? {}; + const { selectedValidator, setSelectedValidator, defaultValidator } = useStakingValidator() ?? {}; const { chainTokenLabel, chainTokenDecimals } = useTokenConfigs(); // Form states @@ -62,6 +62,11 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { const isAmountValid = amountBN && amountBN.gt(0) && amountBN.lte(maxAmountBN); + useEffect(() => { + // Initalize to default validator once on mount + setSelectedValidator(defaultValidator); + }, []); + useEffect(() => { if (amountBN && !isAmountValid) { setError({ @@ -141,13 +146,6 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { }, ]; - const openKeplrDialog = () => - dispatch( - forceOpenDialog({ - type: DialogTypes.ExternalNavKeplr, - }) - ); - const openStrideDialog = () => dispatch( forceOpenDialog({ @@ -170,6 +168,7 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { $Description> <$WithDetailsReceipt side="bottom" detailItems={amountDetailItems}> @@ -185,7 +184,6 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { } /> } - disabled={isLoading} /> $WithDetailsReceipt> @@ -195,16 +193,13 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { fee={fee} isLoading={isLoading} amount={amountBN?.toNumber()} + selectedValidator={selectedValidator} + setSelectedValidator={setSelectedValidator} /> <$LegalDisclaimer> {stringGetter({ - key: STRING_KEYS.STAKING_LEGAL_DISCLAIMER, + key: STRING_KEYS.STAKING_LEGAL_DISCLAIMER_WITH_DEFAULT, params: { - KEPLR_DASHBOARD_LINK: ( - <$Link withIcon onClick={openKeplrDialog}> - {stringGetter({ key: STRING_KEYS.KEPLR_DASHBOARD })} - $Link> - ), STRIDE_LINK: ( <$Link withIcon onClick={openStrideDialog}> Stride @@ -220,6 +215,7 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { const $Form = styled.form` ${formMixins.transfersForm} + --color-text-form: var(--color-text-0); `; const $Description = styled.div` @@ -234,14 +230,16 @@ const $Footer = styled.footer` flex-direction: column; gap: 1rem; `; + const $WithDetailsReceipt = styled(WithDetailsReceipt)` --withReceipt-backgroundColor: var(--color-layer-2); + color: var(--color-text-form); `; const $LegalDisclaimer = styled.div` text-align: center; color: var(--color-text-0); - font: var(--font-small-book); + font: var(--font-mini-book); `; const $Link = styled(Link)` diff --git a/src/views/forms/UnstakeForm/index.tsx b/src/views/forms/UnstakeForm/index.tsx index 6e8fde249..6d98e702c 100644 --- a/src/views/forms/UnstakeForm/index.tsx +++ b/src/views/forms/UnstakeForm/index.tsx @@ -138,10 +138,10 @@ export const UnstakeForm = ({ onDone, className }: UnstakeFormProps) => { key: STRING_KEYS.CURRENTLY_STAKING, params: { AMOUNT: ( - <> + <$StakedAmount> {nativeStakingBalance} {chainTokenLabel} - > + $StakedAmount> ), }, }); @@ -203,6 +203,7 @@ export const UnstakeForm = ({ onDone, className }: UnstakeFormProps) => { ]} > @@ -223,7 +224,6 @@ export const UnstakeForm = ({ onDone, className }: UnstakeFormProps) => { } /> } - disabled={isLoading} /> $WithDetailsReceipt> )} @@ -266,7 +266,6 @@ export const UnstakeForm = ({ onDone, className }: UnstakeFormProps) => { } /> } - disabled={isLoading} /> ); @@ -313,3 +312,8 @@ const $Footer = styled.footer` const $WithDetailsReceipt = styled(WithDetailsReceipt)` --withReceipt-backgroundColor: var(--color-layer-2); `; + +const $StakedAmount = styled.span` + ${layoutMixins.inlineRow} + color: var(--color-text-1); +`;
{stringGetter({ key: isOpen ? STRING_KEYS.TAP_TO_CLOSE : STRING_KEYS.ALL_MARKETS })} - <$DropdownIcon aria-hidden="true"> - - $DropdownIcon> +