From 846d91001a94f71776f19afcce0ff63accfe0911 Mon Sep 17 00:00:00 2001 From: Ignacio Date: Tue, 5 Mar 2024 14:31:20 +0800 Subject: [PATCH] feat: continue with delegation details --- src/features/core/components/base.tsx | 6 +- src/features/core/components/table.tsx | 52 +++ .../staking/components/delegation-details.tsx | 300 ++++++++++++++++++ .../staking/components/modals/staking.tsx | 50 +-- .../staking/components/modals/unstaking.tsx | 51 +-- .../components/validator-delegation.tsx | 83 +++-- .../staking/components/validators-table.tsx | 49 +-- src/features/staking/context/selectors.ts | 11 +- src/features/staking/lib/core/coins.ts | 9 + src/features/staking/lib/core/tx.ts | 38 ++- 10 files changed, 497 insertions(+), 152 deletions(-) create mode 100644 src/features/core/components/table.tsx create mode 100644 src/features/staking/components/delegation-details.tsx diff --git a/src/features/core/components/base.tsx b/src/features/core/components/base.tsx index d191eda..b4f47aa 100644 --- a/src/features/core/components/base.tsx +++ b/src/features/core/components/base.tsx @@ -87,12 +87,12 @@ export const InputBox = ({ error, ...props }: InputProps) => ( - - $ + + XION ); diff --git a/src/features/core/components/table.tsx b/src/features/core/components/table.tsx new file mode 100644 index 0000000..2a82645 --- /dev/null +++ b/src/features/core/components/table.tsx @@ -0,0 +1,52 @@ +import type { PropsWithChildren } from "react"; + +import { chevron } from "@/features/staking/lib/core/icons"; + +type Props = PropsWithChildren & { + onSort?: (method: SortMethod) => void; + sort?: SortMethod; + sorting?: [string, string]; +}; + +export const HeaderTitleBase = ({ + children, + onSort, + sort, + sorting, +}: Props) => { + const sortingOrder = ((sorting || []) as string[]).concat(["none"]); + const sortingIndex = sort ? sortingOrder.indexOf(sort) : -1; + + return ( +
+ + {children}{" "} + {!!onSort && !!sort && ( +
+ ); +}; diff --git a/src/features/staking/components/delegation-details.tsx b/src/features/staking/components/delegation-details.tsx new file mode 100644 index 0000000..8c1d709 --- /dev/null +++ b/src/features/staking/components/delegation-details.tsx @@ -0,0 +1,300 @@ +import { memo, useState } from "react"; + +import { ButtonPill } from "@/features/core/components/base"; +import { HeaderTitleBase } from "@/features/core/components/table"; + +import { useStaking } from "../context/hooks"; +import { setModalOpened } from "../context/reducer"; +import { getTotalDelegation, getTotalUnbonding } from "../context/selectors"; +import type { StakingContextType, StakingState } from "../context/state"; +import { useValidatorLogo } from "../hooks"; +import { coinIsPositive } from "../lib/core/coins"; +import { formatCoin, formatCommission } from "../lib/formatters"; +import AddressShort from "./address-short"; +import TokenColors from "./token-colors"; + +export const getCanShowDetails = (state: StakingState) => { + const userTotalUnbondings = getTotalUnbonding(state, null); + const userTotalDelegation = getTotalDelegation(state, null); + + return ( + coinIsPositive(userTotalUnbondings) || coinIsPositive(userTotalDelegation) + ); +}; + +const gridStyle = { + gap: "16px", + gridTemplateColumns: "60px 1.5fr repeat(4, 1fr)", +}; + +type DelegationRowProps = { + delegation: NonNullable["items"][number]; + disabled?: boolean; + staking: StakingContextType; +}; + +const DelegationRow = ({ + delegation, + disabled, + staking, +}: DelegationRowProps) => { + const validator = (staking.state.validators?.items || []).find( + (v) => v.operatorAddress === delegation.validatorAddress, + ); + + const logo = useValidatorLogo(validator?.description.identity); + + return ( +
+
+
+ Validator logo +
+
+
+
+ {validator?.description.moniker || ""} +
+ +
+
+
+ +
+
+ {validator + ? formatCommission(validator.commission.commissionRates.rate, 0) + : ""} +
+
+ +
+
+ { + if (!validator) return; + + staking.dispatch( + setModalOpened({ + content: { validator }, + type: "delegate", + }), + ); + }} + > + Delegate + + { + if (!validator) return; + + staking.dispatch( + setModalOpened({ + content: { validator }, + type: "undelegate", + }), + ); + }} + > + Undelegate (tmp) + +
+
+
+
+ ); +}; + +type UnbondingRowProps = { + disabled?: boolean; + staking: StakingContextType; + unbonding: NonNullable["items"][number]; +}; + +const UnbondingRow = ({ disabled, staking, unbonding }: UnbondingRowProps) => { + const validator = (staking.state.validators?.items || []).find( + (v) => v.operatorAddress === unbonding.validator, + ); + + const logo = useValidatorLogo(validator?.description.identity); + + return ( +
+
+
+ Validator logo +
+
+
+
+ {validator?.description.moniker || ""} +
+ +
+
+
+ +
+
Unbonding
+
+ {new Date(unbonding.completionTime * 1000).toString()} +
+
+ { + // @TODO + }} + > + Cancel + +
+
+
+
+ ); +}; + +type SortMethod = "bar" | "foo" | "none"; + +const HeaderTitle = HeaderTitleBase; + +const DelegationDetails = () => { + const stakingRef = useStaking(); + + const { staking } = stakingRef; + + const [delegationsSortMethod, setDelegationsSortMethod] = + useState("none"); + + const [unbondingsSortMethod, setUnbondingsSortMethod] = + useState("none"); + + const { delegations, unbondings } = staking.state; + + const hasDelegations = !!delegations?.items.length; + const hasUnbondings = !!unbondings?.items.length; + + if (!hasDelegations && !hasUnbondings) { + return null; + } + + return ( +
+ {hasDelegations && + (() => { + const sortedDelegations = delegations.items.slice(); + + return ( +
+
+
+ Delegations + +
Staked Amount
+
+ +
Commission
+
+ +
Rewards
+
+
+ {sortedDelegations.map((delegation, index) => ( + + ))} +
+ ); + })()} + {hasUnbondings && + (() => { + const sortedUnbondings = unbondings.items.slice(); + + return ( +
+
+
+ Delegations + +
Staked Amount
+
+ +
Commission
+
+ +
Rewards
+
+
+ {sortedUnbondings.map((unbonding, index) => ( + + ))} +
+ ); + })()} +
+ ); +}; + +export default memo(DelegationDetails); diff --git a/src/features/staking/components/modals/staking.tsx b/src/features/staking/components/modals/staking.tsx index 9a1c8b8..60fd25e 100644 --- a/src/features/staking/components/modals/staking.tsx +++ b/src/features/staking/components/modals/staking.tsx @@ -22,9 +22,9 @@ import { getTokensAvailableBG } from "../../context/selectors"; import { getXionCoin } from "../../lib/core/coins"; import { xionToUSD } from "../../lib/core/constants"; import type { StakeAddresses } from "../../lib/core/tx"; -import { formatCoin, formatToSmallDisplay } from "../../lib/formatters"; +import { formatToSmallDisplay } from "../../lib/formatters"; -type Step = "completed" | "input"; +type Step = "completed" | "input" | "review"; // @TODO const initialStep: Step = "input"; @@ -35,7 +35,7 @@ const StakingModal = () => { const [step, setStep] = useState(initialStep); const [isLoading, setIsLoading] = useState(false); - const [amountUSD, setAmount] = useState(""); + const [amountXION, setAmount] = useState(""); const [memo, setMemo] = useState(""); const [formError, setFormError] = useState< @@ -60,12 +60,12 @@ const StakingModal = () => { const { validator } = modal?.content; - const amountXion = (() => { - const amount = parseFloat(amountUSD); + const amountXIONParsed = new BigNumber(amountXION); - if (Number.isNaN(amount)) return ""; + const amountUSD = (() => { + if (amountXIONParsed.isNaN()) return ""; - return new BigNumber(amount / xionToUSD); + return amountXIONParsed.times(xionToUSD); })(); const hasErrors = Object.values(formError).some((v) => !!v); @@ -98,8 +98,8 @@ const StakingModal = () => {
Staked Amount - {amountUSD} - 24 XION + {amountXION} + $24N
{!!memo && (
@@ -119,15 +119,21 @@ const StakingModal = () => { } const getHasAmountError = () => - !amountXion || + !amountUSD || !availableTokens || - amountXion.isNaN() || - amountXion.gt(availableTokens); + amountXIONParsed.isNaN() || + amountXIONParsed.gt(availableTokens); const onSubmit: FormEventHandler = (e) => { e?.stopPropagation(); + e?.preventDefault(); - if (!client || !amountXion || hasErrors || getHasAmountError()) + if ( + !client || + hasErrors || + getHasAmountError() || + amountXIONParsed.lt(0) + ) return; setIsLoading(true); @@ -139,7 +145,7 @@ const StakingModal = () => { stakeValidatorAction( addresses, - getXionCoin(amountXion), + getXionCoin(amountXIONParsed), memo, client, staking, @@ -170,18 +176,18 @@ const StakingModal = () => { return (
- Available for delegation - ${formatToSmallDisplay(availableUSD)} - - {formatCoin(getXionCoin(availableTokens), true)} - + Available for delegation (XION) + + {formatToSmallDisplay(availableTokens)} + + ${formatToSmallDisplay(availableUSD)}
); })()}
Amount
- {!!amountXion && ( - ={formatToSmallDisplay(amountXion)} XION + {!!amountUSD && ( + = ${formatToSmallDisplay(amountUSD)} )}
@@ -204,7 +210,7 @@ const StakingModal = () => { setAmount(e.target.value); }} - value={amountUSD} + value={amountXION} /> {formError.amount && (
diff --git a/src/features/staking/components/modals/unstaking.tsx b/src/features/staking/components/modals/unstaking.tsx index bd4fec9..e7ec9ae 100644 --- a/src/features/staking/components/modals/unstaking.tsx +++ b/src/features/staking/components/modals/unstaking.tsx @@ -21,9 +21,9 @@ import { getTotalDelegation } from "../../context/selectors"; import { getXionCoin } from "../../lib/core/coins"; import { xionToUSD } from "../../lib/core/constants"; import type { StakeAddresses } from "../../lib/core/tx"; -import { formatCoin, formatToSmallDisplay } from "../../lib/formatters"; +import { formatToSmallDisplay, formatXionToUSD } from "../../lib/formatters"; -type Step = "completed" | "input"; +type Step = "completed" | "input" | "review"; // @TODO const initialStep: Step = "input"; @@ -34,7 +34,9 @@ const UnstakingModal = () => { const [step, setStep] = useState(initialStep); const [isLoading, setIsLoading] = useState(false); - const [amountUSD, setAmount] = useState(""); + const unbondingTimeDays = 21; // @TODO + + const [amountXION, setAmount] = useState(""); const [memo, setMemo] = useState(""); const { account, staking } = stakingRef; @@ -54,12 +56,12 @@ const UnstakingModal = () => { const { validator } = modal?.content; - const amountXion = (() => { - const amount = parseFloat(amountUSD); + const amountXIONParsed = new BigNumber(amountXION); - if (Number.isNaN(amount)) return ""; + const amountUSD = (() => { + if (amountXIONParsed.isNaN()) return ""; - return new BigNumber(amount / xionToUSD); + return amountXIONParsed.times(xionToUSD); })(); const delegatedTokens = getTotalDelegation( @@ -69,8 +71,9 @@ const UnstakingModal = () => { const onSubmit: FormEventHandler = (e) => { e?.stopPropagation(); + e?.preventDefault(); - if (!client || !amountXion) return; + if (!client || !amountXIONParsed.gt(0)) return; setIsLoading(true); @@ -81,7 +84,7 @@ const UnstakingModal = () => { unstakeValidatorAction( addresses, - getXionCoin(amountXion), + getXionCoin(amountXIONParsed), memo, client, staking, @@ -120,15 +123,17 @@ const UnstakingModal = () => { SUCCESS!
- You have successfully staked on{" "} - {validator.description.moniker}. Thank you for contributing - in securing the XION network. + You have successfully unstaked from{" "} + {validator.description.moniker}. It takes{" "} + {unbondingTimeDays} days to complete the unstaking process
- Staked Amount - {amountUSD} - 24 XION + Unstaking Amount (XION) + {formatToSmallDisplay(amountXIONParsed)} + + {formatXionToUSD(getXionCoin(amountXIONParsed))} +
{!!memo && (
@@ -162,16 +167,20 @@ const UnstakingModal = () => { return (
- Available for delegation - ${formatToSmallDisplay(availableUSD)} - {formatCoin(delegatedTokens, true)} + Available amount (XION) + + {formatToSmallDisplay( + new BigNumber(delegatedTokens.amount), + )} + + ${formatToSmallDisplay(availableUSD)}
); })()}
Amount
- {!!amountXion && ( - ={formatToSmallDisplay(amountXion)} XION + {!!amountUSD && ( + =${formatToSmallDisplay(amountUSD)} )}
@@ -181,7 +190,7 @@ const UnstakingModal = () => { onChange={(e) => { setAmount(e.target.value); }} - value={amountUSD} + value={amountXION} />
diff --git a/src/features/staking/components/validator-delegation.tsx b/src/features/staking/components/validator-delegation.tsx index 5c87a13..2d74156 100644 --- a/src/features/staking/components/validator-delegation.tsx +++ b/src/features/staking/components/validator-delegation.tsx @@ -19,14 +19,16 @@ import { getValidatorDetailsAction, } from "../context/actions"; import { useStaking } from "../context/hooks"; -import { setIsLoadingBlocking, setModalOpened } from "../context/reducer"; +import { setIsLoadingBlocking } from "../context/reducer"; import { getTokensAvailableBG, getTotalDelegation, getTotalRewards, + getTotalUnbonding, } from "../context/selectors"; import { getXionCoin } from "../lib/core/coins"; import { formatToSmallDisplay, formatXionToUSD } from "../lib/formatters"; +import DelegationDetails, { getCanShowDetails } from "./delegation-details"; import { DivisorVertical } from "./divisor"; export default function ValidatorDelegation() { @@ -34,6 +36,7 @@ export default function ValidatorDelegation() { const address = searchParams.get("address"); const stakingRef = useStaking(); const [, setShowAbstraxion] = useModal(); + const [isShowingDetails, setIsShowingDetails] = useState(false); const { isConnected, staking } = stakingRef; @@ -69,16 +72,15 @@ export default function ValidatorDelegation() { null, ); - const userDelegationToValidator = getTotalDelegation( - stakingRef.staking.state, - validatorDetails.operatorAddress, - ); + const userTotalUnbondings = getTotalUnbonding(stakingRef.staking.state, null); const totalRewards = getTotalRewards( validatorDetails.operatorAddress, stakingRef.staking.state, ); + const canShowDetail = getCanShowDetails(stakingRef.staking.state); + const content = !isConnected ? (
Please log in to view @@ -141,8 +143,8 @@ export default function ValidatorDelegation() {
-
-
+
+
Available For Delegation (XION)
@@ -156,43 +158,26 @@ export default function ValidatorDelegation() { ? formatXionToUSD(getXionCoin(availableToStakeBN)) : "-"} +
+ +
-
- - +
+
+
+ Unstakings (XION) +
+ + {userTotalUnbondings + ? formatToSmallDisplay( + new BigNumber(userTotalUnbondings.amount), + ) + : "-"} + +
+ + {userTotalUnbondings ? formatXionToUSD(userTotalUnbondings) : "-"} +
@@ -200,10 +185,20 @@ export default function ValidatorDelegation() { return ( <> -
+
My Delegations + {canShowDetail && ( + + )}
{content} + {canShowDetail && isShowingDetails && } ); } diff --git a/src/features/staking/components/validators-table.tsx b/src/features/staking/components/validators-table.tsx index 0b14045..10ceaa3 100644 --- a/src/features/staking/components/validators-table.tsx +++ b/src/features/staking/components/validators-table.tsx @@ -1,10 +1,10 @@ "use client"; import BigNumber from "bignumber.js"; -import type { PropsWithChildren } from "react"; import { useState } from "react"; import { ButtonPill, NavLink } from "@/features/core/components/base"; +import { HeaderTitleBase } from "@/features/core/components/table"; import { useStaking } from "../context/hooks"; import { setModalOpened } from "../context/reducer"; @@ -15,7 +15,6 @@ import { import type { StakingContextType, StakingState } from "../context/state"; import { useValidatorLogo } from "../hooks"; import { getXionCoinFromUXion } from "../lib/core/coins"; -import { chevron } from "../lib/core/icons"; import { formatCoin, formatCommission, @@ -113,50 +112,6 @@ const ValidatorRow = ({ ); }; -type Props = PropsWithChildren & { - onSort?: (method: SortMethod) => void; - sort: SortMethod; - sorting?: [string, string]; -}; - -const HeaderTitle = ({ children, onSort, sort, sorting }: Props) => { - const sortingOrder = ((sorting || []) as string[]).concat(["none"]); - const sortingIndex = sortingOrder.indexOf(sort); - - return ( -
- - {children}{" "} - {!!onSort && ( -
- ); -}; - type SortMethod = | "commission-asc" | "commission-desc" @@ -168,6 +123,8 @@ type SortMethod = | "voting-power-asc" | "voting-power-desc"; +const HeaderTitle = HeaderTitleBase; + const ValidatorsTable = () => { const { staking } = useStaking(); const [sortMethod, setSortMethod] = useState("none"); diff --git a/src/features/staking/context/selectors.ts b/src/features/staking/context/selectors.ts index aa43492..0fd3ca0 100644 --- a/src/features/staking/context/selectors.ts +++ b/src/features/staking/context/selectors.ts @@ -20,16 +20,19 @@ export const getTotalDelegation = ( return sumAllCoins(delegationCoins); }; -// @TODO: Should this be included in the delegation total or displayed sepparately? -// eslint-disable-next-line -const getTotalUnbonding = (state: StakingState) => { +export const getTotalUnbonding = ( + state: StakingState, + validatorAddress: null | string, +) => { const { unbondings } = state; if (!unbondings?.items.length) { return null; } - const unbondingCoins = unbondings.items.map((d) => d.balance); + const unbondingCoins = unbondings.items + .filter((d) => !validatorAddress || d.validator === validatorAddress) + .map((d) => d.balance); return sumAllCoins(unbondingCoins); }; diff --git a/src/features/staking/lib/core/coins.ts b/src/features/staking/lib/core/coins.ts index 9f9184c..0b73f9c 100644 --- a/src/features/staking/lib/core/coins.ts +++ b/src/features/staking/lib/core/coins.ts @@ -30,12 +30,21 @@ export const getEmptyXionCoin = () => export const getXionCoin = (bn: BigNumber) => ({ amount: bn.toString(), denom: "XION" }) satisfies Coin; +export const coinIsPositive = (coin: Coin | null) => + coin && new BigNumber(coin.amount).gt(0); + export const getXionCoinFromUXion = (bn: BigNumber) => ({ amount: bn.div(new BigNumber(10).pow(6)).toString(), denom: "XION", }) satisfies Coin; +export const getUXionCoinFromXion = (bn: BigNumber) => + ({ + amount: bn.times(new BigNumber(10).pow(6)).toString(), + denom: "UXION", + }) satisfies Coin; + export const sumAllCoins = (coins: Coin[]) => coins.reduce( (acc, coin) => ({ diff --git a/src/features/staking/lib/core/tx.ts b/src/features/staking/lib/core/tx.ts index 4dca152..bf54230 100644 --- a/src/features/staking/lib/core/tx.ts +++ b/src/features/staking/lib/core/tx.ts @@ -13,9 +13,28 @@ import { } from "cosmjs-types/cosmos/staking/v1beta1/tx"; import type { AbstraxionSigningClient } from "./client"; -import { normaliseCoin } from "./coins"; +import { getUXionCoinFromXion, normaliseCoin } from "./coins"; import { getCosmosFee } from "./fee"; +const getTxCoin = (coin: Coin) => ({ + amount: coin.amount, + denom: ["UXION", "XION"].includes(coin.denom.toUpperCase()) + ? coin.denom.toLowerCase() // Transactions expect lower case + : coin.denom, +}); + +const getUxionAmount = (coin: Coin) => { + if (coin.denom.toUpperCase() === "UXION") { + return getTxCoin(coin); + } + + if (coin.denom.toUpperCase() === "XION") { + return getTxCoin(getUXionCoinFromXion(new BigNumber(coin.amount))); + } + + throw new Error("Invalid coin denom"); +}; + const getTxVerifier = (eventType: string) => (result: DeliverTxResponse) => { // @TODO // eslint-disable-next-line no-console @@ -44,20 +63,13 @@ export type StakeAddresses = { export const stakeAmount = async ( addresses: StakeAddresses, client: NonNullable, - amount: Coin, + initialAmount: Coin, memo: string, ) => { - const uxionAmount = new BigNumber(normaliseCoin(amount).amount) - .times(new BigNumber(10).pow(6)) - .toString(); - - const uxionCoin = { - amount: uxionAmount, - denom: "uxion", - }; + const amount = getUxionAmount(initialAmount); const msg = MsgDelegate.fromPartial({ - amount: uxionCoin, + amount, delegatorAddress: addresses.delegator, validatorAddress: addresses.validator, }); @@ -82,9 +94,11 @@ export const stakeAmount = async ( export const unstakeAmount = async ( addresses: StakeAddresses, client: NonNullable, - amount: Coin, + initialAmount: Coin, memo: string, ) => { + const amount = getUxionAmount(initialAmount); + const msg = MsgUndelegate.fromPartial({ amount, delegatorAddress: addresses.delegator,