diff --git a/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx b/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx index 5f28de4a2a..64f93023a4 100644 --- a/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx +++ b/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx @@ -163,7 +163,7 @@ export const NewBondingPosition = (props: Props): JSX.Element => { variant={ButtonVariant.Contained} onClick={() => { const changeInStakingPosition: ChangeInStakingPosition = { - amount: amountToBond, + amount: amountToBondNumber, owner: currentAddress, validatorId: currentBondingPositions[0].validatorId, }; diff --git a/apps/namada-interface/src/App/Staking/Staking.tsx b/apps/namada-interface/src/App/Staking/Staking.tsx index 83f43c576b..63f2530eda 100644 --- a/apps/namada-interface/src/App/Staking/Staking.tsx +++ b/apps/namada-interface/src/App/Staking/Staking.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { Routes, Route, useNavigate } from "react-router-dom"; +import BigNumber from "bignumber.js"; import { truncateInMiddle } from "@anoma/utils"; import { Modal } from "@anoma/components"; @@ -53,7 +54,7 @@ const validatorNameFromUrl = (path: string): string | undefined => { const emptyStakingPosition = (validatorId: string): StakingPosition => ({ uuid: validatorId, stakingStatus: "", - stakedAmount: "", + stakedAmount: new BigNumber(0), owner: "", totalRewards: "", validatorId: validatorId, @@ -62,7 +63,6 @@ const emptyStakingPosition = (validatorId: string): StakingPosition => ({ type Props = { accounts: Account[]; validators: Validator[]; - myValidators: MyValidators[]; myStakingPositions: StakingPosition[]; selectedValidatorId: string | undefined; // will be called at first load, parent decides what happens @@ -106,7 +106,6 @@ export const Staking = (props: Props): JSX.Element => { postNewBonding, postNewUnbonding, validators, - myValidators, myStakingPositions, selectedValidatorId, } = props; @@ -228,7 +227,6 @@ export const Staking = (props: Props): JSX.Element => { element={ diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/AllValidatorsTable.components.ts b/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/AllValidatorsTable.components.ts new file mode 100644 index 0000000000..08510d45e4 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/AllValidatorsTable.components.ts @@ -0,0 +1,5 @@ +import styled from "styled-components"; + +export const AllValidatorsSearchBar = styled.div` + margin-bottom: 8px; +`; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/AllValidatorsTable.tsx b/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/AllValidatorsTable.tsx new file mode 100644 index 0000000000..1a8edd166a --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/AllValidatorsTable.tsx @@ -0,0 +1,180 @@ +import { useState } from "react"; +import BigNumber from "bignumber.js"; +import { Table, TableLink, TableConfigurations } from "@anoma/components"; +import { Account } from "slices/accounts"; +import { Validator, MyValidators } from "slices/StakingAndGovernance"; +import { + AllValidatorsSearchBar +} from "./AllValidatorsTable.components"; +import { formatPercentage, assertNever } from "@anoma/utils"; +import { useAppSelector, RootState } from "store"; +import { ValidatorsCallbacks } from "../StakingOverview"; + +// AllValidators table row renderer and configuration +// it contains callbacks defined in AllValidatorsCallbacks +const AllValidatorsRowRenderer = ( + validator: Validator, + callbacks?: ValidatorsCallbacks +): JSX.Element => { + return ( + <> + + { + const formattedValidatorName = validator.name + .replace(" ", "-") + .toLowerCase(); + + callbacks && callbacks.onClickValidator(formattedValidatorName); + }} + > + {validator.name} + + + {validator.votingPower?.toString() ?? ""} + {formatPercentage(validator.commission)} + + ); +}; + +const getAllValidatorsConfiguration = ( + navigateToValidatorDetails: (validatorId: string) => void, + onColumnClick: (column: AllValidatorsColumn) => void, + sort: Sort, +): TableConfigurations => { + const getLabelWithTriangle = (column: AllValidatorsColumn): string => { + let triangle = ""; + if (sort.column === column) { + if (sort.ascending) { + triangle = " \u25b5"; // white up-pointing small triangle + } else { + triangle = " \u25bf"; // white down-pointing small triangle + } + } + + return `${column}${triangle}`; + } + + return { + rowRenderer: AllValidatorsRowRenderer, + callbacks: { + onClickValidator: navigateToValidatorDetails, + }, + columns: [ + { + uuid: "1", + columnLabel: getLabelWithTriangle(AllValidatorsColumn.Validator), + width: "45%", + onClick: () => onColumnClick(AllValidatorsColumn.Validator), + }, + { + uuid: "2", + columnLabel: getLabelWithTriangle(AllValidatorsColumn.VotingPower), + width: "25%", + onClick: () => onColumnClick(AllValidatorsColumn.VotingPower), + }, + { + uuid: "3", + columnLabel: getLabelWithTriangle(AllValidatorsColumn.Commission), + width: "30%", + onClick: () => onColumnClick(AllValidatorsColumn.Commission), + }, + ], + }; +}; + +const sortValidators = (sort: Sort, validators: Validator[]): Validator[] => { + const direction = sort.ascending ? 1 : -1; + + const ascendingSortFn: (a: Validator, b: Validator) => number = + sort.column === AllValidatorsColumn.Validator ? + (a, b) => a.name.localeCompare(b.name) : + sort.column === AllValidatorsColumn.VotingPower ? + ((a, b) => + a.votingPower === undefined || b.votingPower === undefined ? 0 : + a.votingPower.isLessThan(b.votingPower) ? -1 : 1) : + sort.column === AllValidatorsColumn.Commission ? + ((a, b) => a.commission.isLessThan(b.commission) ? -1 : 1) : + assertNever(sort.column); + + const cloned = validators.slice(); + cloned.sort((a, b) => direction * ascendingSortFn(a, b)); + + return cloned; +} + +const filterValidators = (search: string, validators: Validator[]): Validator[] => + validators.filter(v => + search === "" + ? true + : v.name.toLowerCase().startsWith(search.toLowerCase()) + ); + +const selectSortedFilteredValidators = (sort: Sort, search: string) => + (state: RootState): Validator[] => { + const validators = state.stakingAndGovernance.validators; + + const sorted = sortValidators(sort, validators); + const sortedAndFiltered = filterValidators(search, sorted); + + return sortedAndFiltered; + }; + +enum AllValidatorsColumn { + Validator = "Validator", + VotingPower = "Voting power", + Commission = "Commission" +}; + +type Sort = { + column: AllValidatorsColumn; + ascending: boolean; +}; + +export const AllValidatorsTable: React.FC<{ + navigateToValidatorDetails: (validatorId: string) => void; +}> = ({ + navigateToValidatorDetails, +}) => { + const [search, setSearch] = useState(""); + + const [sort, setSort] = useState({ + column: AllValidatorsColumn.Validator, + ascending: true + }); + + const sortedFilteredValidators = useAppSelector( + selectSortedFilteredValidators(sort, search) + ); + + const handleColumnClick = (column: AllValidatorsColumn): void => + setSort({ + column, + ascending: sort.column === column ? !sort.ascending : true + }); + + const allValidatorsConfiguration = getAllValidatorsConfiguration( + navigateToValidatorDetails, + handleColumnClick, + sort + ); + + return ( + + setSearch(e.target.value)} + > + + + } + data={sortedFilteredValidators} + tableConfigurations={allValidatorsConfiguration} + /> + ); +}; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/index.ts b/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/index.ts new file mode 100644 index 0000000000..16558c09f6 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/AllValidatorsTable/index.ts @@ -0,0 +1 @@ +export { AllValidatorsTable } from "./AllValidatorsTable"; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/MyValidatorsTable.components.ts b/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/MyValidatorsTable.components.ts new file mode 100644 index 0000000000..08510d45e4 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/MyValidatorsTable.components.ts @@ -0,0 +1,5 @@ +import styled from "styled-components"; + +export const AllValidatorsSearchBar = styled.div` + margin-bottom: 8px; +`; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/MyValidatorsTable.tsx b/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/MyValidatorsTable.tsx new file mode 100644 index 0000000000..ce9f331675 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/MyValidatorsTable.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import BigNumber from "bignumber.js"; +import { Table, TableLink, TableConfigurations } from "@anoma/components"; +import { Account } from "slices/accounts"; +import { Validator, MyValidators, StakingAndGovernanceState } from "slices/StakingAndGovernance"; +import { formatPercentage, assertNever, showMaybeNam } from "@anoma/utils"; +import { useAppSelector, RootState } from "store"; +import { ValidatorsCallbacks } from "../StakingOverview"; + +import * as fake from "slices/StakingAndGovernance/fakeData"; + +const MyValidatorsRowRenderer = ( + myValidatorRow: MyValidators, + callbacks?: ValidatorsCallbacks +): JSX.Element => { + return ( + <> + + + + + + + ); +}; + +const getMyValidatorsConfiguration = ( + navigateToValidatorDetails: (validatorId: string) => void +): TableConfigurations => { + return { + rowRenderer: MyValidatorsRowRenderer, + columns: [ + { uuid: "1", columnLabel: "Validator", width: "30%" }, + { uuid: "2", columnLabel: "Status", width: "20%" }, + { uuid: "3", columnLabel: "Staked Amount", width: "30%" }, + { uuid: "4", columnLabel: "Unbonded Amount", width: "30%" }, + { uuid: "5", columnLabel: "Withdrawable Amount", width: "30%" }, + ], + callbacks: { + onClickValidator: navigateToValidatorDetails, + }, + }; +}; + +export const MyValidatorsTable: React.FC<{ + navigateToValidatorDetails: (validatorId: string) => void; +}> = ({ + navigateToValidatorDetails, +}) => { + const stakingAndGovernanceState = useAppSelector( + state => state.stakingAndGovernance); + const myValidators = stakingAndGovernanceState.myValidators ?? []; + + const myValidatorsConfiguration = getMyValidatorsConfiguration( + navigateToValidatorDetails + ); + + return ( +
+ { + const formattedValidatorName = myValidatorRow.validator.name + .replace(" ", "-") + .toLowerCase(); + + // this function is defined at + // there it triggers a navigation. It then calls a callback + // that was passed to it by its' parent + // in that callback function that is defined in + // an action is dispatched to fetch validator data and make in available + callbacks && callbacks.onClickValidator(formattedValidatorName); + }} + > + {myValidatorRow.validator.name} + + {myValidatorRow.stakingStatus}{showMaybeNam(myValidatorRow.stakedAmount)}{showMaybeNam(myValidatorRow.unbondedAmount)}{showMaybeNam(myValidatorRow.withdrawableAmount)}
+ ); +}; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/index.ts b/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/index.ts new file mode 100644 index 0000000000..b167ea2e11 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/MyValidatorsTable/index.ts @@ -0,0 +1 @@ +export { MyValidatorsTable } from "./MyValidatorsTable"; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/StakingBalancesList.components.ts b/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/StakingBalancesList.components.ts new file mode 100644 index 0000000000..65d1720f65 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/StakingBalancesList.components.ts @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +export const StakingBalances = styled.div` + width: 100%; + display: grid; + grid-template-columns: minmax(150px, 30%) 1fr; +`; +export const StakingBalancesLabel = styled.div``; + +export const StakingBalancesValue = styled.div``; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/StakingBalancesList.tsx b/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/StakingBalancesList.tsx new file mode 100644 index 0000000000..040f26ce39 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/StakingBalancesList.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import BigNumber from "bignumber.js"; +import { Table, TableLink, TableConfigurations } from "@anoma/components"; +import { + StakingBalances, + StakingBalancesLabel, + StakingBalancesValue, +} from "./StakingBalancesList.components"; +import { Account } from "slices/accounts"; +import { Validator, MyValidators, StakingAndGovernanceState } from "slices/StakingAndGovernance"; +import { formatPercentage, assertNever, assertDevOnly, showMaybeNam, nullishMap } from "@anoma/utils"; +import { useAppSelector, RootState } from "store"; + +import * as fake from "slices/StakingAndGovernance/fakeData"; + +type Totals = { + totalBonded: BigNumber, + totalUnbonded: BigNumber, + totalWithdrawable: BigNumber, +}; + +const selectStakingTotals = (state: RootState): Totals | undefined => { + const { myValidators } = useAppSelector( + state => state.stakingAndGovernance); + + if (myValidators === undefined) { + return undefined; + } + + const totalBonded = myValidators.reduce( + (acc, validator) => acc.plus(validator.stakedAmount ?? 0), + new BigNumber(0) + ); + const totalUnbonded = myValidators.reduce( + (acc, validator) => acc.plus(validator.unbondedAmount ?? 0), + new BigNumber(0) + ); + const totalWithdrawable = myValidators.reduce( + (acc, validator) => acc.plus(validator.withdrawableAmount ?? 0), + new BigNumber(0) + ); + + return { + totalBonded, + totalUnbonded, + totalWithdrawable, + }; +}; + +const selectTotalNamBalance = (state: RootState): BigNumber => { + const { chainId } = state.settings; + const { derived } = state.accounts; + const accounts = Object.values(derived[chainId]); + + return accounts.reduce((acc, curr) => { + return acc.plus(curr.balance["NAM"] ?? new BigNumber(0)); + }, new BigNumber(0)); +}; + +const showTotalIfDefined = (totals: Totals | undefined, key: keyof Totals): string => + showMaybeNam(nullishMap(t => t[key], totals)); + +export const StakingBalancesList: React.FC = () => { + const totals = useAppSelector(selectStakingTotals); + const availableForBonding = useAppSelector(selectTotalNamBalance); + + return ( + + Available for bonding + NAM {availableForBonding.toString()} + + Total Bonded + {showTotalIfDefined(totals, "totalBonded")} + + Total Unbonded + {showTotalIfDefined(totals, "totalUnbonded")} + + Total Withdrawable + {showTotalIfDefined(totals, "totalWithdrawable")} + + Pending Rewards + TBD + + ); +}; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/index.ts b/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/index.ts new file mode 100644 index 0000000000..50e123877b --- /dev/null +++ b/apps/namada-interface/src/App/Staking/StakingOverview/StakingBalancesList/index.ts @@ -0,0 +1 @@ +export { StakingBalancesList } from "./StakingBalancesList"; diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx b/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx index 9f5daecba3..0f371f80dd 100644 --- a/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx +++ b/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import BigNumber from "bignumber.js"; import { Table, TableLink, TableConfigurations } from "@anoma/components"; import { Account } from "slices/accounts"; @@ -8,107 +9,20 @@ import { StakingBalancesValue, StakingOverviewContainer, } from "./StakingOverview.components"; - -const MyValidatorsRowRenderer = ( - myValidatorRow: MyValidators, - callbacks?: ValidatorsCallbacks -): JSX.Element => { - return ( - <> - - - - - ); -}; - -const getMyValidatorsConfiguration = ( - navigateToValidatorDetails: (validatorId: string) => void -): TableConfigurations => { - return { - rowRenderer: MyValidatorsRowRenderer, - columns: [ - { uuid: "1", columnLabel: "Validator", width: "30%" }, - { uuid: "2", columnLabel: "Status", width: "40%" }, - { uuid: "3", columnLabel: "Staked Amount", width: "30%" }, - ], - callbacks: { - onClickValidator: navigateToValidatorDetails, - }, - }; -}; +import { formatPercentage, assertNever } from "@anoma/utils"; +import { AllValidatorsTable } from "./AllValidatorsTable"; +import { MyValidatorsTable } from "./MyValidatorsTable"; +import { StakingBalancesList } from "./StakingBalancesList"; // callbacks in this type are specific to a certain row type -type ValidatorsCallbacks = { +export type ValidatorsCallbacks = { onClickValidator: (validatorId: string) => void; }; -// AllValidators table row renderer and configuration -// it contains callbacks defined in AllValidatorsCallbacks -const AllValidatorsRowRenderer = ( - validator: Validator, - callbacks?: ValidatorsCallbacks -): JSX.Element => { - // this is now as a placeholder but in real case it will be in StakingOverview - return ( - <> - - - - - ); -}; - -const getAllValidatorsConfiguration = ( - navigateToValidatorDetails: (validatorId: string) => void -): TableConfigurations => { - return { - rowRenderer: AllValidatorsRowRenderer, - callbacks: { - onClickValidator: navigateToValidatorDetails, - }, - columns: [ - { uuid: "1", columnLabel: "Validator", width: "45%" }, - { uuid: "2", columnLabel: "Voting power", width: "25%" }, - { uuid: "3", columnLabel: "Commission", width: "30%" }, - ], - }; -}; - type Props = { accounts: Account[]; navigateToValidatorDetails: (validatorId: string) => void; validators: Validator[]; - myValidators: MyValidators[]; }; // This is the default view for the staking. it displays all the relevant @@ -118,56 +32,19 @@ type Props = { // view in the parent // * user can also navigate to sibling view for validator details export const StakingOverview = (props: Props): JSX.Element => { - const { navigateToValidatorDetails, validators, myValidators, accounts } = + const { navigateToValidatorDetails, validators, accounts } = props; - // we get the configurations for 2 tables that contain callbacks - const myValidatorsConfiguration = getMyValidatorsConfiguration( - navigateToValidatorDetails - ); - const allValidatorsConfiguration = getAllValidatorsConfiguration( - navigateToValidatorDetails - ); - const totalBonded = myValidators.reduce( - (acc, validator) => acc.plus(validator.stakedAmount), - new BigNumber(0) - ); - const totalBalance = accounts.reduce((acc, curr) => { - return acc.plus(curr.balance["NAM"] ?? new BigNumber(0)); - }, new BigNumber(0)); - return ( - {/* my balances */} - - Total Balance - NAM {totalBalance.toString()} - - Total Bonded - NAM {totalBonded.toString()} - - Pending Rewards - TBD - - Available for bonding - - NAM {totalBalance.minus(totalBonded).toString()} - - + - {/* my validators */} -
- { - const formattedValidatorName = myValidatorRow.validator.name - .replace(" ", "-") - .toLowerCase(); - - // this function is defined at - // there it triggers a navigation. It then calls a callback - // that was passed to it by its' parent - // in that callback function that is defined in - // an action is dispatched to fetch validator data and make in available - callbacks && callbacks.onClickValidator(formattedValidatorName); - }} - > - {myValidatorRow.validator.name} - - {myValidatorRow.stakingStatus}NAM {myValidatorRow.stakedAmount} - { - const formattedValidatorName = validator.name - .replace(" ", "-") - .toLowerCase(); - - callbacks && callbacks.onClickValidator(formattedValidatorName); - }} - > - {validator.name} - - {validator.votingPower}{validator.commission}
- {/* all validators */} -
); diff --git a/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx b/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx index 606080e04e..f5570e5b82 100644 --- a/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx +++ b/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import BigNumber from "bignumber.js"; import { Button, @@ -74,7 +75,7 @@ export const UnbondPosition = (props: Props): JSX.Element => { (pos) => pos.owner === owner ); - const stakedAmount = Number(currentBondingPosition?.stakedAmount || "0"); + const stakedAmount = new BigNumber(currentBondingPosition?.stakedAmount || "0"); // storing the bonding amount input value locally here as string // we threat them as strings except below in validation @@ -95,14 +96,14 @@ export const UnbondPosition = (props: Props): JSX.Element => { // unbonding amount and displayed value with a very naive validation // TODO (https://github.com/anoma/namada-interface/issues/4#issuecomment-1260564499) // do proper validation as part of input - const amountToUnstakeAsNumber = Number(amountToBondOrUnbond); - const remainsBonded = stakedAmount - amountToUnstakeAsNumber; + const amountToUnstakeAsNumber = new BigNumber(amountToBondOrUnbond); + const remainsBonded = stakedAmount.minus(amountToUnstakeAsNumber); // if the input value is incorrect we display an error const isEntryIncorrect = - (amountToBondOrUnbond !== "" && amountToUnstakeAsNumber <= 0) || - remainsBonded < 0 || - Number.isNaN(amountToUnstakeAsNumber); + (amountToBondOrUnbond !== "" && amountToUnstakeAsNumber.isLessThanOrEqualTo(0)) || + remainsBonded.isLessThan(0) || + amountToUnstakeAsNumber.isNaN(); // if the input value is incorrect or empty we disable the confirm button const isEntryIncorrectOrEmpty = @@ -113,10 +114,6 @@ export const UnbondPosition = (props: Props): JSX.Element => { ? `The unbonding amount can be more than 0 and at most ${stakedAmount}` : `${remainsBonded}`; - // This is the value that we pass to be dispatch to the action - const delta = amountToUnstakeAsNumber * -1; - const deltaAsString = `${delta}`; - // data for the summary table const unbondingSummary = [ { @@ -151,7 +148,7 @@ export const UnbondPosition = (props: Props): JSX.Element => { variant={ButtonVariant.Contained} onClick={() => { const changeInStakingPosition: ChangeInStakingPosition = { - amount: deltaAsString, + amount: amountToUnstakeAsNumber, owner: owner as string, validatorId: currentBondingPositions[0].validatorId, }; diff --git a/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx b/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx index 38531ac1bb..5a13dacd44 100644 --- a/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx +++ b/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx @@ -1,6 +1,7 @@ import { Outlet } from "react-router-dom"; +import BigNumber from "bignumber.js"; -import { truncateInMiddle } from "@anoma/utils"; +import { truncateInMiddle, formatPercentage, showMaybeNam } from "@anoma/utils"; import { Button, ButtonVariant, @@ -21,7 +22,7 @@ const validatorDetailsConfigurations: TableConfigurations = { rowRenderer: (rowData: KeyValueData) => { // we have to figure if this is the row for validator homepage, hench an anchor - const linkOrText = rowData.value.startsWith("https:") ? ( + const linkOrText = /^https?:/.test(rowData.value) ? ( {rowData.value} @@ -59,7 +60,7 @@ const getMyStakingWithValidatorConfigurations = ( @@ -54,7 +56,7 @@ const getRenderedDataRows = ( export const Table = ( props: Props ): JSX.Element => { - const { data, tableConfigurations, title, className } = props; + const { data, tableConfigurations, title, className, subheadingSlot } = props; const { columns, rowRenderer, callbacks } = tableConfigurations && tableConfigurations; @@ -69,6 +71,7 @@ export const Table = ( return (

{title}

+ {subheadingSlot}
{renderedRows} diff --git a/packages/components/src/Table/types.ts b/packages/components/src/Table/types.ts index a2eb4fa743..b48805b6f4 100644 --- a/packages/components/src/Table/types.ts +++ b/packages/components/src/Table/types.ts @@ -2,6 +2,7 @@ export type ColumnDefinition = { uuid: string; columnLabel: string; width: string; + onClick?: () => void; }; export type TableConfigurations = { diff --git a/packages/shared/lib/src/query.rs b/packages/shared/lib/src/query.rs index 33bc13187a..62840530d9 100644 --- a/packages/shared/lib/src/query.rs +++ b/packages/shared/lib/src/query.rs @@ -52,7 +52,7 @@ impl Query { .validator_addresses(&self.client, &None) .await?; - let mut result: Vec<(Address, token::Amount)> = Vec::new(); + let mut result: Vec<(Address, Option)> = Vec::new(); for address in validator_addresses.into_iter() { let total_bonds = RPC @@ -61,7 +61,7 @@ impl Query { .validator_stake(&self.client, &address, &None) .await?; - result.push((address, total_bonds.unwrap_or(token::Amount::zero()))); + result.push((address, total_bonds.map(|amount| amount.to_string_native()))); } to_js_result(result) @@ -102,19 +102,28 @@ impl Query { validators_per_address.insert(address, validators); } - //TODO: Change to Vec of structs - //Owner, Validator, Amount - let mut result: Vec<(Address, Address, token::Amount)> = Vec::new(); + let mut result: Vec<(Address, Address, String, String, String)> = + Vec::new(); + let epoch = namada::ledger::rpc::query_epoch(&self.client).await; for (owner, validators) in validators_per_address.into_iter() { for validator in validators.into_iter() { - let total_bonds = RPC + let owner_option = &Some(owner.clone()); + let validator_option = &Some(validator.clone()); + + let enriched = RPC .vp() .pos() - .bond(&self.client, &owner, &validator, &None) + .enriched_bonds_and_unbonds(&self.client, epoch, owner_option, validator_option) .await?; - result.push((owner.clone(), validator, total_bonds)); + result.push(( + owner.clone(), + validator, + enriched.bonds_total.to_string_native(), + enriched.unbonds_total.to_string_native(), + enriched.total_withdrawable.to_string_native(), + )); } } diff --git a/packages/shared/lib/src/sdk/tx.rs b/packages/shared/lib/src/sdk/tx.rs index 01887dddfd..0a6b80ab8a 100644 --- a/packages/shared/lib/src/sdk/tx.rs +++ b/packages/shared/lib/src/sdk/tx.rs @@ -59,7 +59,7 @@ pub fn bond_tx_args(tx_msg: &[u8], password: Option) -> Result) -> Result { - return amount.multipliedBy(MICRO_FACTOR); -}; - -/** - * Amount from Micro - */ -export const amountFromMicro = (micro: BigNumber): BigNumber => { - return micro.dividedBy(MICRO_FACTOR); -}; +export const showMaybeNam = (maybeNam: BigNumber | undefined): string => + nullishMap(nam => `NAM ${nam.toString()}`, maybeNam) ?? "-"; /** * Format a proper JSON RPC request from method and params @@ -151,6 +140,12 @@ export const assertNever = (x: never): never => { return x; }; +export const assertDevOnly = (test: boolean, message: string): void => { + if (process.env.NODE_ENV === "development" && !test) { + throw new Error(message); + } +} + export type Ok = { ok: true; value: T }; export type Err = { ok: false; error: E }; @@ -240,3 +235,11 @@ export type SchemaObject = } : never : never; + +export const formatPercentage = (bigNumber: BigNumber): string => + bigNumber.multipliedBy(100).toString() + "%"; + +export const nullishMap = (f: (a: A) => B, a: A | undefined): B | undefined => + a === undefined + ? undefined + : f(a);
{truncateInMiddle(stakingPosition.owner || "", 5, 5)} {stakingPosition.stakingStatus} - NAM {stakingPosition.stakedAmount}{" "} + {showMaybeNam(stakingPosition.stakedAmount)}{" "} { setModalState(ModalState.Unbond); @@ -101,8 +102,12 @@ const validatorToDataRows = ( } return [ { uuid: "1", key: "Name", value: truncateInMiddle(validator.name, 5, 5) }, - { uuid: "2", key: "Commission", value: validator.commission }, - { uuid: "3", key: "Voting Power", value: validator.votingPower }, + { + uuid: "2", + key: "Commission", + value: formatPercentage(validator.commission), + }, + { uuid: "3", key: "Voting Power", value: validator.votingPower?.toString() ?? "" }, { uuid: "4", key: "Description", diff --git a/apps/namada-interface/src/App/StakingAndGovernance/StakingAndGovernance.tsx b/apps/namada-interface/src/App/StakingAndGovernance/StakingAndGovernance.tsx index 21aac1330f..ead310a39f 100644 --- a/apps/namada-interface/src/App/StakingAndGovernance/StakingAndGovernance.tsx +++ b/apps/namada-interface/src/App/StakingAndGovernance/StakingAndGovernance.tsx @@ -45,7 +45,7 @@ export const StakingAndGovernance = (): JSX.Element => { const [_integration, _status, withConnection] = useIntegrationConnection(chainId); - const { validators, myValidators, selectedValidatorId, myStakingPositions } = + const { validators, selectedValidatorId, myStakingPositions } = stakingAndGovernance; // we need one of the sub routes, staking alone has nothing @@ -97,7 +97,6 @@ export const StakingAndGovernance = (): JSX.Element => { ({ +import { getToast, Toasts } from "slices/transfers"; +import { actions as notificationsActions } from "slices/notifications"; + +const toValidator = ( + [address, stake]: [string, string | null] +): Validator => ({ uuid: address, name: address, - // TODO: voting power is multiplied by votes_per_token value defined in genesis file - // currently it is 10 - votingPower: new BigNumber(votingPower).multipliedBy(10).toString(), + votingPower: stake === null ? undefined : new BigNumber(stake), homepageUrl: "http://namada.net", - commission: "TBD", + commission: new BigNumber(0), // TODO: implement commission description: "TBD", }); const toMyValidators = ( acc: MyValidators[], - [_, validator, stake]: [string, string, string] + [_, validator, stake, unbonded, withdrawable]: [string, string, string, string, string] ): MyValidators[] => { const index = acc.findIndex((myValidator) => myValidator.uuid === validator); const v = acc[index]; @@ -49,8 +54,14 @@ const toMyValidators = ( ]; const stakedAmount = new BigNumber(stake) - .plus(new BigNumber(v?.stakedAmount || 0)) - .toString(); + .plus(new BigNumber(v?.stakedAmount || 0)); + + const unbondedAmount = + (new BigNumber(unbonded)).plus(new BigNumber(v?.unbondedAmount || 0)); + + const withdrawableAmount = + (new BigNumber(withdrawable)).plus(new BigNumber(v?.withdrawableAmount || 0)); + return [ ...sliceFn(acc, index), @@ -58,7 +69,9 @@ const toMyValidators = ( uuid: validator, stakingStatus: "Bonded", stakedAmount, - validator: toValidator([validator, stakedAmount]), + unbondedAmount, + withdrawableAmount, + validator: toValidator([validator, stakedAmount.toString()]), }, ]; }; @@ -70,14 +83,12 @@ const toStakingPosition = ([owner, validator, stake]: [ ]): StakingPosition => ({ uuid: owner + validator, stakingStatus: "Bonded", - stakedAmount: stake, + stakedAmount: new BigNumber(stake), owner, totalRewards: "TBD", validatorId: validator, }); -// this retrieves the validators -// this dispatches further actions that are depending on -// validators data + export const fetchValidators = createAsyncThunk< { allValidators: Validator[] }, void, @@ -87,9 +98,12 @@ export const fetchValidators = createAsyncThunk< const { rpc } = chains[chainId]; const query = new Query(rpc); - const allValidators = (await query.query_all_validators()).map(toValidator); + const queryResult = + (await query.query_all_validators()) as [string, string | null][]; + const allValidators = queryResult.map(toValidator); thunkApi.dispatch(fetchMyValidators(allValidators)); + return Promise.resolve({ allValidators }); }); @@ -161,18 +175,50 @@ export const postNewBonding = createAsyncThunk< const { chainId } = thunkApi.getState().settings; const integration = getIntegration(chainId); const signer = integration.signer() as Signer; - await signer.submitBond({ - source: change.owner, - validator: change.validatorId, - amount: new BigNumber(change.amount), - nativeToken: Tokens.NAM.address || "", - tx: { - token: Tokens.NAM.address || "", - feeAmount: new BigNumber(0), - gasLimit: new BigNumber(0), - chainId, - }, - }); + + const toastId = `${thunkApi.requestId}-transfer`; + + thunkApi.dispatch( + notificationsActions.createToast( + getToast(toastId, Toasts.TransferStarted)({ + msgId: "Staking..." + }) + ) + ); + + let success = true; + try { + await signer.submitBond({ + source: change.owner, + validator: change.validatorId, + amount: change.amount, + nativeToken: Tokens.NAM.address || "", + tx: { + token: Tokens.NAM.address || "", + feeAmount: new BigNumber(0), + gasLimit: new BigNumber(0), + chainId, + }, + }); + } catch (e) { + success = false; + } + + thunkApi.dispatch( + notificationsActions.createToast( + getToast(toastId, Toasts.TransferCompleted)({ + success, + msgId: success + ? "Staking completed" + : "Staking did not complete" + }) + ) + ); + + if (success) { + thunkApi.dispatch(fetchBalances()); + thunkApi.dispatch(fetchValidators()); + } }); // we post an unstake transaction @@ -188,15 +234,42 @@ export const postNewUnbonding = createAsyncThunk< const { chainId } = thunkApi.getState().settings; const integration = getIntegration(chainId); const signer = integration.signer() as Signer; - await signer.submitUnbond({ - source: change.owner, - validator: change.validatorId, - amount: new BigNumber(change.amount), - tx: { - token: Tokens.NAM.address || "", - feeAmount: new BigNumber(0), - gasLimit: new BigNumber(0), - chainId, - }, - }); + + const toastId = `${thunkApi.requestId}-transfer`; + + thunkApi.dispatch( + notificationsActions.createToast( + getToast(toastId, Toasts.TransferStarted)({ + msgId: "Unbonding..." + }) + ) + ); + + let success = true; + try { + await signer.submitUnbond({ + source: change.owner, + validator: change.validatorId, + amount: change.amount, + tx: { + token: Tokens.NAM.address || "", + feeAmount: new BigNumber(0), + gasLimit: new BigNumber(0), + chainId, + }, + }); + } catch (e) { + success = false; + } + + thunkApi.dispatch( + notificationsActions.createToast( + getToast(toastId, Toasts.TransferCompleted)({ + success, + msgId: success + ? "Unbonding completed" + : "Unbonding did not complete" + }) + ) + ); }); diff --git a/apps/namada-interface/src/slices/StakingAndGovernance/fakeData.ts b/apps/namada-interface/src/slices/StakingAndGovernance/fakeData.ts index e6480bcb5a..e3c81361bc 100644 --- a/apps/namada-interface/src/slices/StakingAndGovernance/fakeData.ts +++ b/apps/namada-interface/src/slices/StakingAndGovernance/fakeData.ts @@ -1,3 +1,5 @@ +import BigNumber from "bignumber.js"; + import { Validator, StakingPosition, MyBalanceEntry } from "./types"; export const myBalancesData: MyBalanceEntry[] = [ { @@ -30,7 +32,7 @@ export const myStakingData: StakingPosition[] = [ { uuid: "1", stakingStatus: "Bonded", - stakedAmount: "10.00", + stakedAmount: new BigNumber(10_000_000), owner: "some-owner", totalRewards: "0.55", validatorId: "polychain-capital", @@ -38,7 +40,7 @@ export const myStakingData: StakingPosition[] = [ { uuid: "2", stakingStatus: "Bonded Pending", - stakedAmount: "3.00", + stakedAmount: new BigNumber(3_000_000), owner: "some-owner", totalRewards: "0.15", validatorId: "coinbase-custody", @@ -46,7 +48,7 @@ export const myStakingData: StakingPosition[] = [ { uuid: "3", stakingStatus: "Unboding (22 days left)", - stakedAmount: "20.00", + stakedAmount: new BigNumber(20_000_000), owner: "some-owner", totalRewards: "1.05", validatorId: "kraken", @@ -58,8 +60,8 @@ export const allValidatorsData: Validator[] = [ uuid: "polychain-capital", name: "Polychain capital", homepageUrl: "https://polychain.capital", - votingPower: "NAM 100 000", - commission: "22%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.22), description: "Polychain is an investment firm committed to exceptional returns for investors through actively managed portfolios of blockchain assets.", }, @@ -67,8 +69,8 @@ export const allValidatorsData: Validator[] = [ uuid: "figment", name: "Figment", homepageUrl: "https://figment.io", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "Makers of Hubble and Canada’s largest Cosmos validator, Figment is the easiest and most secure way to stake your Atoms.", }, @@ -76,8 +78,8 @@ export const allValidatorsData: Validator[] = [ uuid: "p2p", name: "P2P", homepageUrl: "https://p2p.org", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "One of the winners of Cosmos Game of Stakes. We provide a simple, secure and intelligent staking service to help you generate rewards on your blockchain assets across 9+ networks within a single interface. Let’s stake together - p2p.org.", }, @@ -85,16 +87,16 @@ export const allValidatorsData: Validator[] = [ uuid: "coinbase-custody", name: "Coinbase Custody", homepageUrl: "https://custody.coinbase.com", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "Coinbase Custody Cosmos Validator", }, { uuid: "chorus-one", name: "Chorus One", homepageUrl: "https://chorus.one", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "Secure Cosmos and shape its future by delegating to Chorus One, a highly secure and stable validator. By delegating, you agree to the terms of service at: https://chorus.one/cosmos/tos", }, @@ -102,16 +104,16 @@ export const allValidatorsData: Validator[] = [ uuid: "binance-staking", name: "Binance Staking", homepageUrl: "https://binance.com", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "Exchange the world", }, { uuid: "dokiacapital", name: "DokiaCapital", homepageUrl: "https://staking.dokia.cloud", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "Downtime is not an option for Dokia Capital. We operate an enterprise-grade infrastructure that is robust and secure.", }, @@ -119,16 +121,16 @@ export const allValidatorsData: Validator[] = [ uuid: "kraken", name: "Kraken", homepageUrl: "https://kraken.com", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "Kraken Exchange validator", }, { uuid: "zero-knowledge-validator-(ZKV)", name: "Zero Knowledge Validator (ZKV)", homepageUrl: "https://zkvalidator.com", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "Zero Knowledge Validator: Stake & Support ZKP Research & Privacy Tech", }, @@ -136,8 +138,8 @@ export const allValidatorsData: Validator[] = [ uuid: "paradigm", name: "Paradigm", homepageUrl: "https://www.paradigm.xyz", - votingPower: "NAM 100 000", - commission: "20%", + votingPower: new BigNumber(100_000), + commission: new BigNumber(0.20), description: "", }, ]; diff --git a/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts b/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts index 542e5b938b..6cc7a86ea7 100644 --- a/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts +++ b/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts @@ -15,7 +15,7 @@ import { const initialState: StakingAndGovernanceState = { validators: [], - myValidators: [], + myValidators: undefined, myStakingPositions: [], stakingOrUnstakingState: StakingOrUnstakingState.Idle, }; diff --git a/apps/namada-interface/src/slices/StakingAndGovernance/types.ts b/apps/namada-interface/src/slices/StakingAndGovernance/types.ts index f9bdea9677..d4e4dca7af 100644 --- a/apps/namada-interface/src/slices/StakingAndGovernance/types.ts +++ b/apps/namada-interface/src/slices/StakingAndGovernance/types.ts @@ -1,3 +1,5 @@ +import BigNumber from "bignumber.js"; + export const STAKING_AND_GOVERNANCE = "stakingAndGovernance"; export const FETCH_VALIDATORS = `${STAKING_AND_GOVERNANCE}/FETCH_VALIDATORS`; export const FETCH_MY_VALIDATORS = `${STAKING_AND_GOVERNANCE}/FETCH_MY_VALIDATORS`; @@ -21,16 +23,16 @@ type Unique = { // represents the details of a validator export type Validator = Unique & { name: string; - votingPower: string; + votingPower?: BigNumber; homepageUrl: string; - commission: string; + commission: BigNumber; description: string; }; // represents users staking position export type StakingPosition = Unique & { stakingStatus: string; - stakedAmount: string; + stakedAmount: BigNumber; owner: string; totalRewards: string; validatorId: ValidatorId; @@ -39,7 +41,9 @@ export type StakingPosition = Unique & { // represents users staking position combined with the validator export type MyValidators = Unique & { stakingStatus: string; - stakedAmount: string; + stakedAmount?: BigNumber; + unbondedAmount?: BigNumber; + withdrawableAmount?: BigNumber; validator: Validator; }; @@ -63,17 +67,15 @@ export enum StakingOrUnstakingState { } // this represents a change in staking position -// if positive, we are posting new bonding -// negative, we are decreasing it export type ChangeInStakingPosition = { validatorId: ValidatorId; owner: string; - amount: string; + amount: BigNumber; }; export type StakingAndGovernanceState = { validators: Validator[]; - myValidators: MyValidators[]; + myValidators?: MyValidators[]; myStakingPositions: StakingPosition[]; selectedValidatorId?: ValidatorId; stakingOrUnstakingState: StakingOrUnstakingState; diff --git a/packages/components/src/Table/Table.tsx b/packages/components/src/Table/Table.tsx index 974228852c..63b8888df7 100644 --- a/packages/components/src/Table/Table.tsx +++ b/packages/components/src/Table/Table.tsx @@ -6,6 +6,7 @@ export type Props = { data: RowType[]; tableConfigurations: TableConfigurations; className?: string; + subheadingSlot?: JSX.Element; }; const getRenderedHeaderRow = ( @@ -22,6 +23,7 @@ const getRenderedHeaderRow = ( {columnDefinition.columnLabel}