From 530d13f2ad10bb1ba832b1746613f8676bbdd2d4 Mon Sep 17 00:00:00 2001 From: Ignacio Date: Thu, 7 Mar 2024 00:14:11 +0800 Subject: [PATCH] feat: improve ui and data collection --- .../staking/components/delegation-details.tsx | 6 +-- .../staking/components/modals/rewards.tsx | 39 ++++++++++++------ .../staking/components/modals/staking.tsx | 41 ++++++++++--------- .../staking/components/modals/unstaking.tsx | 38 +++++++++++++++++ .../staking/components/staking-overview.tsx | 30 +++++++++++--- .../components/validator-delegation.tsx | 17 ++++---- src/features/staking/context/actions.ts | 23 +++++++---- src/features/staking/context/reducer.ts | 18 ++++++++ src/features/staking/context/selectors.ts | 4 ++ src/features/staking/context/state.tsx | 4 +- src/features/staking/lib/core/base.ts | 11 +++-- src/features/staking/lib/core/client.ts | 9 +++- src/features/staking/lib/core/constants.ts | 6 ++- src/features/staking/lib/core/fee.ts | 2 +- src/features/staking/lib/core/tx.ts | 22 +++++----- src/features/staking/lib/formatters.ts | 8 ++++ 16 files changed, 204 insertions(+), 74 deletions(-) diff --git a/src/features/staking/components/delegation-details.tsx b/src/features/staking/components/delegation-details.tsx index b3c891f..2f1519e 100644 --- a/src/features/staking/components/delegation-details.tsx +++ b/src/features/staking/components/delegation-details.tsx @@ -157,7 +157,7 @@ const DelegationRowBase = ({ staking.dispatch( setModalOpened({ content: { - validator, + delegations: [delegation], }, type: "rewards", }), @@ -204,7 +204,7 @@ const UnbondingRow = ({ unbonding, validator, }: UnbondingRowProps) => { - const { client, staking } = stakingRef; + const { account, client, staking } = stakingRef; const logo = useValidatorLogo(validator?.description.identity); const validatorAddress = unbonding.validator; @@ -247,7 +247,7 @@ const UnbondingRow = ({ if (!client) return; const addresses = { - delegator: staking.state.tokens?.denom || "", + delegator: account.bech32Address || "", validator: unbonding.validator, }; diff --git a/src/features/staking/components/modals/rewards.tsx b/src/features/staking/components/modals/rewards.tsx index 327157a..20f8dc7 100644 --- a/src/features/staking/components/modals/rewards.tsx +++ b/src/features/staking/components/modals/rewards.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useRef, useState } from "react"; +import { toast } from "react-toastify"; import { Button, HeroText } from "@/features/core/components/base"; import CommonModal from "@/features/core/components/common-modal"; @@ -18,18 +19,32 @@ const claimRewards = async ( const { client, staking } = stakingRef; const { modal } = staking.state; - const validatorAddress = modal?.content.validator.operatorAddress; - - if (!client || !validatorAddress) return; - - const addresses = { - delegator: stakingRef.account.bech32Address, - validator: validatorAddress, - }; - - claimRewardsAction(addresses, client, stakingRef.staking).finally(() => { - setStep("completed"); - }); + const { delegations } = modal?.content || {}; + + if (!client || !delegations?.length) return; + + delegations + .reduce(async (promise, delegation) => { + await promise; + + const addresses = { + delegator: stakingRef.account.bech32Address, + validator: delegation.validatorAddress, + }; + + return claimRewardsAction(addresses, client, stakingRef.staking); + }, Promise.resolve()) + .then(() => { + setStep("completed"); + }) + .catch(() => { + toast( + "There was an unexpected error claiming your rewards. Please try again later.", + { + type: "error", + }, + ); + }); }; const RewardsModal = () => { diff --git a/src/features/staking/components/modals/staking.tsx b/src/features/staking/components/modals/staking.tsx index e6e013f..43b07ce 100644 --- a/src/features/staking/components/modals/staking.tsx +++ b/src/features/staking/components/modals/staking.tsx @@ -58,6 +58,8 @@ const StakingModal = () => { const { validator } = modal?.content; + if (!validator) return null; + const amountXIONParsed = new BigNumber(amountXION); const amountUSD = (() => { @@ -177,23 +179,29 @@ const StakingModal = () => { ); } - const getHasAmountError = () => - !amountUSD || - !availableTokens || - amountXIONParsed.isNaN() || - amountXIONParsed.gt(availableTokens); + const validateAmount = () => { + if ( + !amountUSD || + !availableTokens || + amountXIONParsed.isNaN() || + amountXIONParsed.gt(availableTokens) + ) { + setFormError({ + ...formError, + amount: "Invalid amount", + }); + + return true; + } + }; const onSubmit: FormEventHandler = (e) => { e?.stopPropagation(); e?.preventDefault(); - if ( - !client || - hasErrors || - getHasAmountError() || - amountXIONParsed.lt(0) - ) - return; + if (validateAmount()) return; + + if (!client || hasErrors || amountXIONParsed.lt(0)) return; setStep("review"); }; @@ -229,12 +237,7 @@ const StakingModal = () => { disabled={isLoading} error={!!formError.amount} onBlur={() => { - if (getHasAmountError()) { - setFormError({ - ...formError, - amount: "Invalid amount", - }); - } + validateAmount(); }} onChange={(e) => { if (formError.amount) { @@ -263,7 +266,7 @@ const StakingModal = () => {
diff --git a/src/features/staking/components/modals/unstaking.tsx b/src/features/staking/components/modals/unstaking.tsx index 94f7fa0..78adc46 100644 --- a/src/features/staking/components/modals/unstaking.tsx +++ b/src/features/staking/components/modals/unstaking.tsx @@ -5,6 +5,7 @@ import { toast } from "react-toastify"; import { Button, + FormError, Heading2, Heading8, HeroText, @@ -32,6 +33,10 @@ const UnstakingModal = () => { const [step, setStep] = useState(initialStep); const [isLoading, setIsLoading] = useState(false); + const [formError, setFormError] = useState< + Record + >({ amount: undefined, memo: undefined }); + const [amountXION, setAmount] = useState(""); const [memo, setMemo] = useState(""); @@ -43,6 +48,7 @@ const UnstakingModal = () => { () => () => { setStep(initialStep); setAmount(""); + setFormError({}); setMemo(""); }, [isOpen], @@ -52,6 +58,8 @@ const UnstakingModal = () => { const { validator } = modal?.content; + if (!validator) return null; + const amountXIONParsed = new BigNumber(amountXION); const amountUSD = (() => { @@ -65,10 +73,28 @@ const UnstakingModal = () => { validator.operatorAddress, ); + const validateAmount = () => { + if ( + !amountUSD || + !delegatedTokens || + amountXIONParsed.lte(0) || + amountXIONParsed.gt(new BigNumber(delegatedTokens.amount)) + ) { + setFormError({ + ...formError, + amount: "Invalid amount", + }); + + return true; + } + }; + const onSubmit: FormEventHandler = (e) => { e?.stopPropagation(); e?.preventDefault(); + if (validateAmount()) return; + if (!client || !amountXIONParsed.gt(0)) return; setStep("review"); @@ -217,11 +243,23 @@ const UnstakingModal = () => {
{ + validateAmount(); + }} onChange={(e) => { + if (formError.amount) { + setFormError({ ...formError, amount: undefined }); + } + setAmount(e.target.value); }} value={amountXION} /> + {formError.amount && ( +
+ {formError.amount} +
+ )}
{ const totalRewards = getTotalRewards(null, staking.state) || getEmptyXionCoin(); + const apr = getAPR(staking.state); + const availableDelegation = staking.state.tokens || getEmptyXionCoin(); return ( @@ -71,8 +78,21 @@ const StakingOverview = () => { Claimable Rewards
{formatXionToUSD(totalRewards)} - {getIsMinimumClaimable(totalRewards) /* @TODO */ && ( - Claim + {getIsMinimumClaimable(totalRewards) && ( + { + staking.dispatch( + setModalOpened({ + content: { + delegations: staking.state.delegations?.items || [], + }, + type: "rewards", + }), + ); + }} + > + Claim + )}
{formatCoin(totalRewards)} @@ -82,7 +102,7 @@ const StakingOverview = () => {
APR - 15.57% + {formatAPR(apr)}
diff --git a/src/features/staking/components/validator-delegation.tsx b/src/features/staking/components/validator-delegation.tsx index 1cb03f8..0ba7a56 100644 --- a/src/features/staking/components/validator-delegation.tsx +++ b/src/features/staking/components/validator-delegation.tsx @@ -65,21 +65,18 @@ export default function ValidatorDelegation() { return
Loading ...
; } - const availableToStakeBN = getTokensAvailableBG(stakingRef.staking.state); + const availableToStakeBN = getTokensAvailableBG(staking.state); - const userTotalDelegation = getTotalDelegation( - stakingRef.staking.state, - null, - ); + const userTotalDelegation = getTotalDelegation(staking.state, null); - const userTotalUnbondings = getTotalUnbonding(stakingRef.staking.state, null); + const userTotalUnbondings = getTotalUnbonding(staking.state, null); const totalRewards = getTotalRewards( validatorDetails.operatorAddress, - stakingRef.staking.state, + staking.state, ); - const canShowDetail = getCanShowDetails(stakingRef.staking.state); + const canShowDetail = getCanShowDetails(staking.state); const content = !isConnected ? (
@@ -106,10 +103,10 @@ export default function ValidatorDelegation() { { - stakingRef.staking.dispatch( + staking.dispatch( setModalOpened({ content: { - validator: validatorDetails, + delegations: staking.state.delegations?.items || [], }, type: "rewards", }), diff --git a/src/features/staking/context/actions.ts b/src/features/staking/context/actions.ts index e7e0c42..b9d811b 100644 --- a/src/features/staking/context/actions.ts +++ b/src/features/staking/context/actions.ts @@ -3,6 +3,7 @@ import type { Coin } from "@cosmjs/stargate"; import { getBalance, getDelegations, + getInflation, getPool, getRewards, getUnbondingDelegations, @@ -17,6 +18,7 @@ import { addDelegations, addUnbondings, setExtraValidators, + setInflation, setIsInfoLoading, setPool, setTokens, @@ -30,13 +32,19 @@ export const fetchStakingDataAction = async (staking: StakingContextType) => { try { staking.dispatch(setIsInfoLoading(true)); - const [validatorsBonded, validatorsUnbonded, validatorsUnbonding, pool] = - await Promise.all([ - getValidatorsList("BOND_STATUS_BONDED"), - getValidatorsList("BOND_STATUS_UNBONDED"), - getValidatorsList("BOND_STATUS_UNBONDING"), - getPool(), - ]); + const [ + validatorsBonded, + validatorsUnbonded, + validatorsUnbonding, + inflation, + pool, + ] = await Promise.all([ + getValidatorsList("BOND_STATUS_BONDED"), + getValidatorsList("BOND_STATUS_UNBONDED"), + getValidatorsList("BOND_STATUS_UNBONDING"), + getInflation(), + getPool(), + ]); ( [ @@ -59,6 +67,7 @@ export const fetchStakingDataAction = async (staking: StakingContextType) => { }); staking.dispatch(setPool(pool)); + staking.dispatch(setInflation(inflation.toString())); staking.dispatch(setIsInfoLoading(false)); } catch (error) { diff --git a/src/features/staking/context/reducer.ts b/src/features/staking/context/reducer.ts index 27fa4b8..06990f6 100644 --- a/src/features/staking/context/reducer.ts +++ b/src/features/staking/context/reducer.ts @@ -25,6 +25,10 @@ export type StakingAction = content: StakingState["extraValidators"]; type: "SET_EXTRA_VALIDATORS"; } + | { + content: StakingState["inflation"]; + type: "SET_INFLATION"; + } | { content: StakingState["isInfoLoading"]; type: "SET_IS_INFO_LOADING"; @@ -116,6 +120,13 @@ export const setModalOpened = ( type: "SET_MODAL", }); +export const setInflation = ( + content: Content<"SET_INFLATION">, +): StakingAction => ({ + content, + type: "SET_INFLATION", +}); + export const setExtraValidators = ( content: Content<"SET_EXTRA_VALIDATORS">, ): StakingAction => ({ @@ -289,6 +300,13 @@ export const reducer = ( }; } + case "SET_INFLATION": { + return { + ...state, + inflation: action.content, + }; + } + default: action satisfies never; diff --git a/src/features/staking/context/selectors.ts b/src/features/staking/context/selectors.ts index dfe096e..44199b7 100644 --- a/src/features/staking/context/selectors.ts +++ b/src/features/staking/context/selectors.ts @@ -104,3 +104,7 @@ export const getAllValidators = ( [v.operatorAddress]: v, }; }, state.extraValidators); + +// As discussed internally, in XION the APR is the same as the inflation +export const getAPR = (state: StakingState) => + state.inflation ? new BigNumber(state.inflation) : null; diff --git a/src/features/staking/context/state.tsx b/src/features/staking/context/state.tsx index 74c9452..de26c94 100644 --- a/src/features/staking/context/state.tsx +++ b/src/features/staking/context/state.tsx @@ -29,7 +29,7 @@ type Delegation = { }; type ModalContent = { - content: { validator: Validator }; + content: { delegations?: Delegation[]; validator?: Validator }; type: "delegate" | "rewards" | "undelegate"; } | null; @@ -38,6 +38,7 @@ export type ValidatorStatus = "bonded" | "unbonded" | "unbonding"; export type StakingState = { delegations: Paginated; extraValidators: Record; + inflation: null | string; isInfoLoading: boolean; modal: ModalContent | null; pool: null | Pool; @@ -55,6 +56,7 @@ export type StakingContextType = { export const defaultState: StakingState = { delegations: null, extraValidators: {}, + inflation: null, isInfoLoading: false, modal: null, pool: null, diff --git a/src/features/staking/lib/core/base.ts b/src/features/staking/lib/core/base.ts index f275b57..a147711 100644 --- a/src/features/staking/lib/core/base.ts +++ b/src/features/staking/lib/core/base.ts @@ -47,9 +47,6 @@ export const getValidatorDetails = async (address: string) => { return promise; }; -// @TODO: This returns the unbonding time -// const params = await queryClient.staking.params(); - let poolRequest: null | Promise = null; export const getPool = async () => { @@ -116,3 +113,11 @@ export const getRewards = async (address: string, validatorAddress: string) => { })) .map((r) => normaliseCoin(r)); }; + +export const getInflation = async () => { + const queryClient = await getStakingQueryClient(); + + const params = await queryClient.mint.params().catch(() => null); + + return params?.inflationMax || 0.2; +}; diff --git a/src/features/staking/lib/core/client.ts b/src/features/staking/lib/core/client.ts index a0a93ef..2603f26 100644 --- a/src/features/staking/lib/core/client.ts +++ b/src/features/staking/lib/core/client.ts @@ -3,6 +3,7 @@ import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; import type { DistributionExtension, IbcExtension, + MintExtension, StakingExtension, } from "@cosmjs/stargate"; import { @@ -11,6 +12,7 @@ import { StargateClient, setupDistributionExtension, setupIbcExtension, + setupMintExtension, setupStakingExtension, } from "@cosmjs/stargate"; import { Tendermint34Client } from "@cosmjs/tendermint-rpc"; @@ -23,7 +25,11 @@ export type AbstraxionSigningClient = NonNullable< let stakingQueryClientPromise: | Promise< - QueryClient & StakingExtension & DistributionExtension & IbcExtension + QueryClient & + StakingExtension & + DistributionExtension & + IbcExtension & + MintExtension > | undefined = undefined; @@ -36,6 +42,7 @@ export const getStakingQueryClient = () => { cometClient, setupStakingExtension, setupDistributionExtension, + setupMintExtension, setupIbcExtension, ); })(); diff --git a/src/features/staking/lib/core/constants.ts b/src/features/staking/lib/core/constants.ts index 6244b7d..48895ae 100644 --- a/src/features/staking/lib/core/constants.ts +++ b/src/features/staking/lib/core/constants.ts @@ -20,5 +20,9 @@ export const xionToUSD = 10; export const defaultAvatar = `${basePath}/default-avatar.svg`; // Even if this can be retrieved from the params, hardcode it to avoid and -// extra request +// extra request. It can be retrieved with this: +// const params = await queryClient.staking.params(); export const unbondingDays = isTestnet ? 3 : 21; + +// Arbitrary value to avoid using a bigger fee than the actual reward +export const minClaimableXion = 0.0001; diff --git a/src/features/staking/lib/core/fee.ts b/src/features/staking/lib/core/fee.ts index 28eec66..b3887a0 100644 --- a/src/features/staking/lib/core/fee.ts +++ b/src/features/staking/lib/core/fee.ts @@ -52,7 +52,7 @@ export const getCosmosFee = async ({ memo = "", msgs, }: FeeOpts): Promise => { - const gasEstimate = await simulateMsgsWithExec(msgs, memo); + const gasEstimate = await simulateMsgsWithExec(msgs, memo).catch(() => null); const fee: StdFee = { amount, diff --git a/src/features/staking/lib/core/tx.ts b/src/features/staking/lib/core/tx.ts index 7f7c676..feb6794 100644 --- a/src/features/staking/lib/core/tx.ts +++ b/src/features/staking/lib/core/tx.ts @@ -1,9 +1,9 @@ -import type { - Coin, - DeliverTxResponse, - MsgDelegateEncodeObject, - MsgUndelegateEncodeObject, - MsgWithdrawDelegatorRewardEncodeObject, +import { + type Coin, + type DeliverTxResponse, + type MsgDelegateEncodeObject, + type MsgUndelegateEncodeObject, + type MsgWithdrawDelegatorRewardEncodeObject, } from "@cosmjs/stargate"; import BigNumber from "bignumber.js"; import { MsgWithdrawDelegatorReward } from "cosmjs-types/cosmos/distribution/v1beta1/tx"; @@ -15,6 +15,7 @@ import { import type { AbstraxionSigningClient } from "./client"; import { getUXionCoinFromXion, normaliseCoin } from "./coins"; +import { minClaimableXion } from "./constants"; import { getCosmosFee } from "./fee"; const getTxCoin = (coin: Coin) => ({ @@ -129,7 +130,7 @@ export const claimRewards = async ( ) => { const msg = MsgWithdrawDelegatorReward.fromPartial({ delegatorAddress: addresses.delegator, - validatorAddress: addresses.validator, + // validatorAddress: addresses.validator, }); const messageWrapper = [ @@ -151,7 +152,6 @@ export const claimRewards = async ( }; export const getIsMinimumClaimable = (amount: Coin) => { - const minClaimableXion = 0.0001; const normalised = normaliseCoin(amount); return new BigNumber(normalised.amount).gte(minClaimableXion); @@ -167,9 +167,9 @@ export const cancelUnstake = async ( }); const messageWrapper = { - typeUrl: "/cosmos.staking.v1beta1.MsgCancelUnbondingDelegation" as string, + typeUrl: "/cosmos.staking.v1beta1.MsgCancelUnbondingDelegation", value: msg, - } as MsgUndelegateEncodeObject; // cosmjs doesn't have yet this encode object + }; const fee = await getCosmosFee({ address: addresses.delegator, @@ -177,7 +177,7 @@ export const cancelUnstake = async ( }); return await client - .signAndBroadcast(addresses.delegator, [messageWrapper], fee) + .signAndBroadcast(addresses.delegator, [messageWrapper], fee, "") .then((result) => { // @TODO // eslint-disable-next-line no-console diff --git a/src/features/staking/lib/formatters.ts b/src/features/staking/lib/formatters.ts index 720a15f..b050830 100644 --- a/src/features/staking/lib/formatters.ts +++ b/src/features/staking/lib/formatters.ts @@ -82,3 +82,11 @@ export const formatUnbondingCompletionTime = (completionTime: number) => { return `in ${remainingDays} day${remainingDays === 1 ? "" : "s"}, ${month} ${day} ${year}`; }; + +export const formatAPR = (apr: BigNumber | null) => { + if (!apr) { + return "-"; + } + + return `${apr.times(100).toFixed(2)}%`; +};