diff --git a/apps/extension/src/background/keyring/handler.ts b/apps/extension/src/background/keyring/handler.ts index 3705a68d1b..a766222a48 100644 --- a/apps/extension/src/background/keyring/handler.ts +++ b/apps/extension/src/background/keyring/handler.ts @@ -25,6 +25,7 @@ import { QueryBalancesMsg, SubmitBondMsg, SubmitUnbondMsg, + SubmitWithdrawMsg, SubmitIbcTransferMsg, FetchAndStoreMaspParamsMsg, HasMaspParamsMsg, @@ -87,6 +88,8 @@ export const getHandler: (service: KeyRingService) => Handler = (service) => { return handleSubmitBondMsg(service)(env, msg as SubmitBondMsg); case SubmitUnbondMsg: return handleSubmitUnbondMsg(service)(env, msg as SubmitUnbondMsg); + case SubmitWithdrawMsg: + return handleSubmitWithdrawMsg(service)(env, msg as SubmitWithdrawMsg); case SubmitIbcTransferMsg: return handleSubmitIbcTransferMsg(service)( env, @@ -261,6 +264,15 @@ const handleSubmitUnbondMsg: ( }; }; +const handleSubmitWithdrawMsg: ( + service: KeyRingService +) => InternalHandler = (service) => { + return async (_, msg) => { + const { txMsg } = msg; + return await service.submitWithdraw(txMsg); + }; +}; + const handleSubmitIbcTransferMsg: ( service: KeyRingService ) => InternalHandler = (service) => { diff --git a/apps/extension/src/background/keyring/init.ts b/apps/extension/src/background/keyring/init.ts index ac2dfb430d..ebc4d5c82c 100644 --- a/apps/extension/src/background/keyring/init.ts +++ b/apps/extension/src/background/keyring/init.ts @@ -25,6 +25,7 @@ import { EncodeRevealPkMsg, SubmitBondMsg, SubmitUnbondMsg, + SubmitWithdrawMsg, SubmitIbcTransferMsg, FetchAndStoreMaspParamsMsg, HasMaspParamsMsg, @@ -54,6 +55,7 @@ export function init(router: Router, service: KeyRingService): void { router.registerMessage(SubmitBondMsg); router.registerMessage(SubmitIbcTransferMsg); router.registerMessage(SubmitUnbondMsg); + router.registerMessage(SubmitWithdrawMsg); router.registerMessage(TransferCompletedEvent); router.registerMessage(UnlockKeyRingMsg); router.registerMessage(DeleteAccountMsg); diff --git a/apps/extension/src/background/keyring/keyring.ts b/apps/extension/src/background/keyring/keyring.ts index 7de2295021..6f403c0747 100644 --- a/apps/extension/src/background/keyring/keyring.ts +++ b/apps/extension/src/background/keyring/keyring.ts @@ -675,6 +675,18 @@ export class KeyRing { } } + async submitWithdraw(txMsg: Uint8Array): Promise { + if (!this._password) { + throw new Error("Not authenticated!"); + } + + try { + await this.sdk.submit_withdraw(txMsg, this._password); + } catch (e) { + throw new Error(`Could not submit withdraw tx: ${e}`); + } + } + async submitTransfer( txMsg: Uint8Array, submit: (password: string, xsk?: string) => Promise diff --git a/apps/extension/src/background/keyring/service.ts b/apps/extension/src/background/keyring/service.ts index 85278bddcb..60b116890c 100644 --- a/apps/extension/src/background/keyring/service.ts +++ b/apps/extension/src/background/keyring/service.ts @@ -171,6 +171,15 @@ export class KeyRingService { } } + async submitWithdraw(txMsg: string): Promise { + try { + await this._keyRing.submitWithdraw(fromBase64(txMsg)); + } catch (e) { + console.warn(e); + throw new Error(`Unable to submit withdraw tx! ${e}`); + } + } + private async submitTransferChrome( txMsg: string, msgId: string, diff --git a/apps/extension/src/provider/Anoma.ts b/apps/extension/src/provider/Anoma.ts index 9599037f5a..46202cd12e 100644 --- a/apps/extension/src/provider/Anoma.ts +++ b/apps/extension/src/provider/Anoma.ts @@ -14,6 +14,7 @@ import { QueryBalancesMsg, SubmitBondMsg, SubmitUnbondMsg, + SubmitWithdrawMsg, SubmitIbcTransferMsg, } from "./messages"; @@ -97,6 +98,13 @@ export class Anoma implements IAnoma { ); } + public async submitWithdraw(txMsg: string): Promise { + return await this.requester?.sendMessage( + Ports.Background, + new SubmitWithdrawMsg(txMsg) + ); + } + public async submitTransfer(txMsg: string): Promise { return await this.requester?.sendMessage( Ports.Background, diff --git a/apps/extension/src/provider/InjectedAnoma.ts b/apps/extension/src/provider/InjectedAnoma.ts index 8b34a9a3dd..316052ad1f 100644 --- a/apps/extension/src/provider/InjectedAnoma.ts +++ b/apps/extension/src/provider/InjectedAnoma.ts @@ -60,6 +60,13 @@ export class InjectedAnoma implements IAnoma { ); } + public async submitWithdraw(txMsg: string): Promise { + return await InjectedProxy.requestMethod( + "submitWithdraw", + txMsg + ); + } + public async submitTransfer(txMsg: string): Promise { return await InjectedProxy.requestMethod( "submitTransfer", diff --git a/apps/extension/src/provider/Signer.ts b/apps/extension/src/provider/Signer.ts index 36c0f31324..13013d245e 100644 --- a/apps/extension/src/provider/Signer.ts +++ b/apps/extension/src/provider/Signer.ts @@ -14,6 +14,7 @@ import { SubmitBondProps, SubmitBondMsgValue, SubmitUnbondMsgValue, + SubmitWithdrawMsgValue, } from "@anoma/types"; export class Signer implements ISigner { @@ -57,6 +58,18 @@ export class Signer implements ISigner { return await this._anoma.submitUnbond(toBase64(encoded)); } + /** + * Submit withdraw transaction + */ + public async submitWithdraw(args: SubmitBondProps): Promise { + const msgValue = new SubmitWithdrawMsgValue(args); + + const msg = new Message(); + const encoded = msg.encode(msgValue); + + return await this._anoma.submitWithdraw(toBase64(encoded)); + } + /** * Submit a transfer */ diff --git a/apps/extension/src/provider/messages.ts b/apps/extension/src/provider/messages.ts index cb2df89272..a051e9a155 100644 --- a/apps/extension/src/provider/messages.ts +++ b/apps/extension/src/provider/messages.ts @@ -26,6 +26,7 @@ enum MessageType { SuggestChain = "suggest-chain", SubmitBond = "submit-bond", SubmitUnbond = "submit-unbond", + SubmitWithdraw = "submit-withdraw", FetchAndStoreMaspParams = "fetch-and-store-masp-params", HasMaspParams = "has-masp-params", } @@ -308,6 +309,31 @@ export class SubmitUnbondMsg extends Message { } } +export class SubmitWithdrawMsg extends Message { + public static type(): MessageType { + return MessageType.SubmitWithdraw; + } + + constructor(public readonly txMsg: string) { + super(); + } + + validate(): void { + if (!this.txMsg) { + throw new Error("An encoded txMsg is required!"); + } + return; + } + + route(): string { + return Route.KeyRing; + } + + type(): string { + return SubmitWithdrawMsg.type(); + } +} + export class ApproveTransferMsg extends Message { public static type(): MessageType { return MessageType.ApproveTransfer; 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..713a820a0f 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"; @@ -52,8 +53,8 @@ const validatorNameFromUrl = (path: string): string | undefined => { const emptyStakingPosition = (validatorId: string): StakingPosition => ({ uuid: validatorId, - stakingStatus: "", - stakedAmount: "", + bonded: true, + 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..b50d179f3b 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, @@ -14,14 +15,15 @@ import { ValidatorDetailsContainer, StakeButtonContainer, } from "./ValidatorDetails.components"; -import { Validator, StakingPosition } from "slices/StakingAndGovernance"; +import { Validator, StakingPosition, postNewWithdraw } from "slices/StakingAndGovernance"; import { ModalState } from "../Staking"; +import { useAppSelector, RootState, useAppDispatch } from "store"; 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} @@ -44,7 +46,9 @@ const validatorDetailsConfigurations: TableConfigurations = const getMyStakingWithValidatorConfigurations = ( setModalState: React.Dispatch>, - navigateToUnbonding: (validatorId: string, owner: string) => void + navigateToUnbonding: (validatorId: string, owner: string) => void, + dispatchWithdraw: (validatorId: string, owner: string) => void, + epoch?: BigNumber, ): TableConfigurations< StakingPosition, { @@ -57,20 +61,33 @@ const getMyStakingWithValidatorConfigurations = ( return ( <> - + @@ -79,7 +96,7 @@ const getMyStakingWithValidatorConfigurations = ( columns: [ { uuid: "1", columnLabel: "Owner", width: "25%" }, { uuid: "2", columnLabel: "State", width: "25%" }, - { uuid: "3", columnLabel: "Amount Staked", width: "25%" }, + { uuid: "3", columnLabel: "Amount", width: "25%" }, { uuid: "4", columnLabel: "Total Rewards", width: "25%" }, ], }; @@ -101,8 +118,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", @@ -119,9 +140,23 @@ export const ValidatorDetails = (props: Props): JSX.Element => { navigateToUnbonding, stakingPositionsWithSelectedValidator = [], } = props; + + const epoch = useAppSelector((state: RootState) => + state.stakingAndGovernance.epoch); + + const dispatch = useAppDispatch(); + const dispatchWithdraw = (validatorId: string, owner: string): void => { + dispatch(postNewWithdraw({ validatorId, owner })); + } + const validatorDetailsData = validatorToDataRows(validator); const myStakingWithValidatorConfigurations = - getMyStakingWithValidatorConfigurations(setModalState, navigateToUnbonding); + getMyStakingWithValidatorConfigurations( + setModalState, + navigateToUnbonding, + dispatchWithdraw, + epoch + ); return ( 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 +55,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 +70,9 @@ const toMyValidators = ( uuid: validator, stakingStatus: "Bonded", stakedAmount, - validator: toValidator([validator, stakedAmount]), + unbondedAmount, + withdrawableAmount, + validator: toValidator([validator, stakedAmount.toString()]), }, ]; }; @@ -69,15 +83,38 @@ const toStakingPosition = ([owner, validator, stake]: [ string ]): StakingPosition => ({ uuid: owner + validator, - stakingStatus: "Bonded", - stakedAmount: stake, + bonded: true, + stakedAmount: new BigNumber(stake), owner, totalRewards: "TBD", validatorId: validator, }); -// this retrieves the validators -// this dispatches further actions that are depending on -// validators data + +const toBond = ([owner, validator, amount, startEpoch]: + [string, string, string, string]): StakingPosition => { + + return { + uuid: owner + validator + startEpoch, + bonded: true, + stakedAmount: new BigNumber(amount), + owner, + validatorId: validator, + totalRewards: "TBD", + }; +} + +const toUnbond = ([owner, validator, amount, startEpoch, withdrawableEpoch]: + [string, string, string, string, string]): StakingPosition => { + + const bond = toBond([owner, validator, amount, startEpoch]); + + return { + ...bond, + bonded: false, + withdrawableEpoch: new BigNumber(withdrawableEpoch), + } +} + export const fetchValidators = createAsyncThunk< { allValidators: Validator[] }, void, @@ -87,9 +124,14 @@ 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)); + thunkApi.dispatch(fetchMyStakingPositions()); + thunkApi.dispatch(fetchEpoch()); + return Promise.resolve({ allValidators }); }); @@ -113,7 +155,7 @@ export const fetchValidatorDetails = createAsyncThunk< // TODO this or fetchMyStakingPositions is likely redundant based on // real data model stored in the chain, adjust when implementing the real data export const fetchMyValidators = createAsyncThunk< - { myValidators: MyValidators[]; myStakingPositions: StakingPosition[] }, + { myValidators: MyValidators[] }, Validator[], { state: RootState } >(FETCH_MY_VALIDATORS, async (_, thunkApi) => { @@ -131,9 +173,8 @@ export const fetchMyValidators = createAsyncThunk< const myValidatorsRes = await query.query_my_validators(addresses); const myValidators = myValidatorsRes.reduce(toMyValidators, []); - const myStakingPositions = myValidatorsRes.map(toStakingPosition); - return Promise.resolve({ myValidators, myStakingPositions }); + return Promise.resolve({ myValidators }); } catch (error) { console.warn(`error: ${error}`); return Promise.reject({}); @@ -142,9 +183,47 @@ export const fetchMyValidators = createAsyncThunk< export const fetchMyStakingPositions = createAsyncThunk< { myStakingPositions: StakingPosition[] }, - void ->(FETCH_MY_STAKING_POSITIONS, async () => { - return Promise.resolve({ myStakingPositions: myStakingData }); + void, + { state: RootState } +>(FETCH_MY_STAKING_POSITIONS, async (_, thunkApi) => { + try { + const { chainId } = thunkApi.getState().settings; + const { rpc } = chains[chainId]; + + const accounts: Account[] = Object.values( + thunkApi.getState().accounts.derived[chainId] + ); + const addresses = accounts + .filter(({ details }) => !details.isShielded) + .map(({ details }) => details.address); + const query = new Query(rpc); + + const [bonds, unbonds] = await query.query_staking_positions(addresses); + + return Promise.resolve({ + myStakingPositions: [ + ...bonds.map(toBond), + ...unbonds.map(toUnbond), + ] + }); + } catch (error) { + console.warn(`error: ${error}`); + return Promise.reject({}); + } +}); + +export const fetchEpoch = createAsyncThunk< + { epoch: BigNumber }, + void, + { state: RootState } +>(FETCH_EPOCH, async (_, thunkApi) => { + const { chainId } = thunkApi.getState().settings; + const { rpc } = chains[chainId]; + + const query = new Query(rpc); + const epochString = await query.query_epoch(); + + return Promise.resolve({ epoch: new BigNumber(epochString) }); }); // we generate the new staking transaction @@ -161,18 +240,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 +299,98 @@ 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" + }) + ) + ); + + if (success) { + thunkApi.dispatch(fetchValidators()); + } +}); + +export const postNewWithdraw = createAsyncThunk< + void, + { owner: string, validatorId: string }, + { state: RootState } +>(POST_UNSTAKING, async ({ owner, validatorId }, thunkApi) => { + const { chainId } = thunkApi.getState().settings; + const integration = getIntegration(chainId); + const signer = integration.signer() as Signer; + + const toastId = `${thunkApi.requestId}-transfer`; + + thunkApi.dispatch( + notificationsActions.createToast( + getToast(toastId, Toasts.TransferStarted)({ + msgId: "Withdrawing..." + }) + ) + ); + + let success = true; + try { + await signer.submitWithdraw({ + source: owner, + validator: validatorId, + tx: { + token: Tokens.NAM.address || "", + feeAmount: new BigNumber(0), + gasLimit: new BigNumber(0), + chainId, + }, + }); + } catch (e) { + console.log(e); + success = false; + } + + thunkApi.dispatch( + notificationsActions.createToast( + getToast(toastId, Toasts.TransferCompleted)({ + success, + msgId: success + ? "Withdrawal completed" + : "Withdrawal did not complete" + }) + ) + ); + + if (success) { + thunkApi.dispatch(fetchValidators()); + } }); diff --git a/apps/namada-interface/src/slices/StakingAndGovernance/fakeData.ts b/apps/namada-interface/src/slices/StakingAndGovernance/fakeData.ts index e6480bcb5a..f1d5f27749 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[] = [ { @@ -29,24 +31,24 @@ export const myBalancesData: MyBalanceEntry[] = [ export const myStakingData: StakingPosition[] = [ { uuid: "1", - stakingStatus: "Bonded", - stakedAmount: "10.00", + bonded: true, + stakedAmount: new BigNumber(10_000_000), owner: "some-owner", totalRewards: "0.55", validatorId: "polychain-capital", }, { uuid: "2", - stakingStatus: "Bonded Pending", - stakedAmount: "3.00", + bonded: true, + stakedAmount: new BigNumber(3_000_000), owner: "some-owner", totalRewards: "0.15", validatorId: "coinbase-custody", }, { uuid: "3", - stakingStatus: "Unboding (22 days left)", - stakedAmount: "20.00", + bonded: true, + 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/index.ts b/apps/namada-interface/src/slices/StakingAndGovernance/index.ts index 5d66f4c2f5..e4d4c92985 100644 --- a/apps/namada-interface/src/slices/StakingAndGovernance/index.ts +++ b/apps/namada-interface/src/slices/StakingAndGovernance/index.ts @@ -4,6 +4,7 @@ export { fetchValidatorDetails, postNewBonding, postNewUnbonding, + postNewWithdraw, } from "./actions"; export { reducer as stakingAndGovernanceReducers } from "./reducers"; export type { diff --git a/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts b/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts index 542e5b938b..57d5629280 100644 --- a/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts +++ b/apps/namada-interface/src/slices/StakingAndGovernance/reducers.ts @@ -4,6 +4,7 @@ import { fetchValidators, fetchMyValidators, fetchMyStakingPositions, + fetchEpoch, postNewBonding, postNewUnbonding, } from "./actions"; @@ -15,9 +16,10 @@ import { const initialState: StakingAndGovernanceState = { validators: [], - myValidators: [], + myValidators: undefined, myStakingPositions: [], stakingOrUnstakingState: StakingOrUnstakingState.Idle, + epoch: undefined, }; export const stakingAndGovernanceSlice = createSlice({ @@ -37,7 +39,6 @@ export const stakingAndGovernanceSlice = createSlice({ .addCase(fetchMyValidators.fulfilled, (state, action) => { // stop the loader state.myValidators = action.payload.myValidators; - state.myStakingPositions = action.payload.myStakingPositions; }) .addCase(fetchMyValidators.rejected, (state, _action) => { // stop the loader @@ -55,6 +56,9 @@ export const stakingAndGovernanceSlice = createSlice({ // stop the loader state.myStakingPositions = action.payload?.myStakingPositions; }) + .addCase(fetchEpoch.fulfilled, (state, action) => { + state.epoch = action.payload.epoch; + }) .addCase(postNewBonding.pending, (state, _action) => { // stop the loader state.stakingOrUnstakingState = StakingOrUnstakingState.Staking; diff --git a/apps/namada-interface/src/slices/StakingAndGovernance/types.ts b/apps/namada-interface/src/slices/StakingAndGovernance/types.ts index f9bdea9677..dc93c35c81 100644 --- a/apps/namada-interface/src/slices/StakingAndGovernance/types.ts +++ b/apps/namada-interface/src/slices/StakingAndGovernance/types.ts @@ -1,7 +1,10 @@ +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`; export const FETCH_MY_STAKING_POSITIONS = `${STAKING_AND_GOVERNANCE}/FETCH_MY_STAKING_POSITIONS`; +export const FETCH_EPOCH = `${STAKING_AND_GOVERNANCE}/FETCH_EPOCH`; export const FETCH_VALIDATOR_DETAILS = `${STAKING_AND_GOVERNANCE}/FETCH_VALIDATOR_DETAILS`; export const POST_NEW_STAKING = `${STAKING_AND_GOVERNANCE}/POST_NEW_STAKING`; export const POST_UNSTAKING = `${STAKING_AND_GOVERNANCE}/POST_UNSTAKING`; @@ -21,16 +24,17 @@ 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; + bonded: boolean; + withdrawableEpoch?: BigNumber; + stakedAmount: BigNumber; owner: string; totalRewards: string; validatorId: ValidatorId; @@ -39,7 +43,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,18 +69,17 @@ 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; + epoch?: BigNumber; }; diff --git a/packages/components/src/Modal/Modal.components.ts b/packages/components/src/Modal/Modal.components.ts index e800ed7d89..cac6175f74 100644 --- a/packages/components/src/Modal/Modal.components.ts +++ b/packages/components/src/Modal/Modal.components.ts @@ -5,11 +5,13 @@ export const ModalContainer = styled.div` flex-direction: column; align-items: center; width: 100%; + background-color: ${(props) => props.theme.colors.utility1.main80}; `; export const ModalHeader = styled.div` display: flex; justify-content: center; align-items: center; + color: ${(props) => props.theme.colors.utility1.main20}; `; export const ModalContent = styled.div` diff --git a/packages/components/src/Modal/Modal.tsx b/packages/components/src/Modal/Modal.tsx index 2ec18d72bf..304828aeed 100644 --- a/packages/components/src/Modal/Modal.tsx +++ b/packages/components/src/Modal/Modal.tsx @@ -1,4 +1,6 @@ import { default as ReactModal } from "react-modal"; +import { ThemeProvider } from "styled-components"; +import { loadColorMode, getTheme } from "@anoma/utils"; import { ModalContainer, ModalContent, @@ -14,6 +16,9 @@ type Props = { export const Modal = (props: Props): JSX.Element => { const { children, isOpen, title, onBackdropClick } = props; + + const theme = getTheme(loadColorMode()); + return ( { width: "80%", maxWidth: "640px", height: "80%", + padding: 0, }, }} isOpen={isOpen} ariaHideApp={false} > - - - {title} - - {children} - + + + + {title} + + {children} + + ); }; 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 = ( @@ -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..06820976da 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(), + )); } } @@ -137,6 +146,75 @@ impl Query { result } + pub async fn query_staking_positions( + &self, + owner_addresses: Box<[JsValue]>, + ) -> Result { + let owner_addresses: Vec
= owner_addresses + .into_iter() + .map(|address| { + //TODO: Handle errors(unwrap) + let address_str = &(address.as_string().unwrap()[..]); + Address::from_str(address_str).unwrap() + }) + .collect(); + + let mut validators_per_address: HashMap> = HashMap::new(); + + for address in owner_addresses.into_iter() { + let validators = RPC + .vp() + .pos() + .delegation_validators(&self.client, &address) + .await?; + + validators_per_address.insert(address, validators); + } + + let mut bonds: Vec<(Address, Address, String, String)> = + Vec::new(); + let mut unbonds: 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 owner_option = &Some(owner.clone()); + let validator_option = &Some(validator.clone()); + + let enriched = RPC + .vp() + .pos() + .enriched_bonds_and_unbonds(&self.client, epoch, owner_option, validator_option) + .await?; + + for (bond_id, details) in &enriched.data { + for bond in &details.data.bonds { + bonds.push(( + bond_id.source.clone(), + bond_id.validator.clone(), + bond.amount.to_string_native(), + bond.start.to_string(), + )); + } + + for unbond in &details.data.unbonds { + unbonds.push(( + bond_id.source.clone(), + bond_id.validator.clone(), + unbond.amount.to_string_native(), + unbond.start.to_string(), + unbond.withdraw.to_string(), + )); + } + + } + } + } + + to_js_result((bonds, unbonds)) + } + /// Queries transparent balance for a given address /// /// # Arguments diff --git a/packages/shared/lib/src/sdk/mod.rs b/packages/shared/lib/src/sdk/mod.rs index e1ee893674..065f878395 100644 --- a/packages/shared/lib/src/sdk/mod.rs +++ b/packages/shared/lib/src/sdk/mod.rs @@ -288,6 +288,23 @@ impl Sdk { Ok(()) } + + pub async fn submit_withdraw( + &mut self, + tx_msg: &[u8], + password: Option, + ) -> Result<(), JsError> { + let args = tx::withdraw_tx_args(tx_msg, password)?; + + let (tx, _, pk) = + namada::ledger::tx::build_withdraw(&mut self.client, &mut self.wallet, args.clone()) + .await + .map_err(JsError::from)?; + + self.sign_and_process_tx(args.tx, tx, pk).await?; + + Ok(()) + } } #[wasm_bindgen(module = "/src/sdk/mod.js")] diff --git a/packages/shared/lib/src/sdk/tx.rs b/packages/shared/lib/src/sdk/tx.rs index 01887dddfd..a3e6ba6f9a 100644 --- a/packages/shared/lib/src/sdk/tx.rs +++ b/packages/shared/lib/src/sdk/tx.rs @@ -10,7 +10,7 @@ use namada::{ key::common::PublicKey as PK, key::ed25519::PublicKey, masp::{ExtendedSpendingKey, PaymentAddress, TransferSource, TransferTarget}, - token::{Amount, DenominatedAmount, Denomination}, + token::{Amount, DenominatedAmount, Denomination, NATIVE_MAX_DECIMAL_PLACES}, transaction::GasLimit, }, }; @@ -59,7 +59,7 @@ pub fn bond_tx_args(tx_msg: &[u8], password: Option) -> Result) -> Result) -> Result) -> Result { + let tx_msg = SubmitWithdrawMsg::try_from_slice(tx_msg)?; + + let SubmitWithdrawMsg { + source, + validator, + tx, + } = tx_msg; + + let source = Address::from_str(&source)?; + let validator = Address::from_str(&validator)?; + + let args = args::Withdraw { + tx: tx_msg_into_args(tx, password)?, + validator, + source: Some(source), + tx_code_path: PathBuf::from("tx_withdraw.wasm"), + }; + + Ok(args) +} + #[derive(BorshSerialize, BorshDeserialize)] pub struct SubmitTransferMsg { tx: TxMsg, diff --git a/packages/types/src/anoma.ts b/packages/types/src/anoma.ts index e0335a5fb9..2ae57e3b25 100644 --- a/packages/types/src/anoma.ts +++ b/packages/types/src/anoma.ts @@ -13,6 +13,7 @@ export interface Anoma { chains: () => Promise; submitBond: (txMsg: string) => Promise; submitUnbond: (txMsg: string) => Promise; + submitWithdraw: (txMsg: string) => Promise; submitTransfer: (txMsg: string) => Promise; submitIbcTransfer: (txMsg: string) => Promise; encodeInitAccount: (props: { diff --git a/packages/types/src/signer.ts b/packages/types/src/signer.ts index 163061072f..29876d0223 100644 --- a/packages/types/src/signer.ts +++ b/packages/types/src/signer.ts @@ -4,6 +4,7 @@ import { InitAccountProps, SubmitBondProps, SubmitUnbondProps, + SubmitWithdrawProps, TransferProps, } from "./tx"; @@ -11,6 +12,7 @@ export interface Signer { accounts: () => Promise; submitBond(args: SubmitBondProps): Promise; submitUnbond(args: SubmitUnbondProps): Promise; + submitWithdraw(args: SubmitWithdrawProps): Promise; submitTransfer(args: TransferProps): Promise; submitIbcTransfer(args: IbcTransferProps): Promise; encodeInitAccount( diff --git a/packages/types/src/tx/schema/index.ts b/packages/types/src/tx/schema/index.ts index 65c1b1bf35..f241c31799 100644 --- a/packages/types/src/tx/schema/index.ts +++ b/packages/types/src/tx/schema/index.ts @@ -3,16 +3,19 @@ export * from "./ibcTransfer"; export * from "./transfer"; export * from "./bond"; export * from "./unbond"; +export * from "./withdraw"; import { AccountMsgValue } from "./account"; import { IbcTransferMsgValue } from "./ibcTransfer"; import { TransferMsgValue } from "./transfer"; import { SubmitBondMsgValue } from "./bond"; import { SubmitUnbondMsgValue } from "./unbond"; +import { SubmitWithdrawMsgValue } from "./withdraw"; export type Schema = | AccountMsgValue | IbcTransferMsgValue | TransferMsgValue | SubmitBondMsgValue - | SubmitUnbondMsgValue; + | SubmitUnbondMsgValue + | SubmitWithdrawMsgValue; diff --git a/packages/types/src/tx/schema/withdraw.ts b/packages/types/src/tx/schema/withdraw.ts new file mode 100644 index 0000000000..eaf85de525 --- /dev/null +++ b/packages/types/src/tx/schema/withdraw.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { field } from "@dao-xyz/borsh"; +import { TxMsgValue } from "./tx"; +import { SubmitWithdrawProps } from "../types"; + +export class SubmitWithdrawMsgValue { + @field({ type: "string" }) + source!: string; + + @field({ type: "string" }) + validator!: string; + + @field({ type: TxMsgValue }) + tx!: TxMsgValue; + + constructor(data: SubmitWithdrawProps) { + Object.assign(this, data); + this.tx = new TxMsgValue({ ...data.tx, publicKey: data.tx.publicKey }); + } +} diff --git a/packages/types/src/tx/types.ts b/packages/types/src/tx/types.ts index 0764b4b21c..824586b510 100644 --- a/packages/types/src/tx/types.ts +++ b/packages/types/src/tx/types.ts @@ -15,6 +15,12 @@ export type SubmitUnbondProps = { tx: TxProps; }; +export type SubmitWithdrawProps = { + validator: string; + source: string; + tx: TxProps; +}; + export type TxProps = { token: string; feeAmount: BigNumber; diff --git a/packages/utils/src/helpers/index.ts b/packages/utils/src/helpers/index.ts index ab1e5e7a67..03cf5bad4b 100644 --- a/packages/utils/src/helpers/index.ts +++ b/packages/utils/src/helpers/index.ts @@ -6,19 +6,8 @@ import BN from "bn.js"; const MICRO_FACTOR = 1000000; // 1,000,000 -/** - * Amount to Micro - */ -export const amountToMicro = (amount: BigNumber): BigNumber => { - 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}{stakingPosition.bonded ? "Bonded" : "Unbonded"} - NAM {stakingPosition.stakedAmount}{" "} - { - setModalState(ModalState.Unbond); - navigateToUnbonding( - stakingPosition.validatorId, - stakingPosition.owner - ); - }} - > - unstake - + {showMaybeNam(stakingPosition.stakedAmount)}{" "} + { + stakingPosition.bonded ? + { + setModalState(ModalState.Unbond); + navigateToUnbonding( + stakingPosition.validatorId, + stakingPosition.owner + ); + }} + > + unstake + : + + epoch && stakingPosition.withdrawableEpoch?.isLessThanOrEqualTo(epoch) && + dispatchWithdraw( + stakingPosition.validatorId, + stakingPosition.owner, + )} + > + withdraw + + } {stakingPosition.totalRewards} {columnDefinition.columnLabel}