From 693f8422804e9c0a628d13bb5a72cf75c4ef3465 Mon Sep 17 00:00:00 2001 From: Ignacio Date: Fri, 1 Mar 2024 20:26:16 +0800 Subject: [PATCH] feat: improve validator details --- src/features/staking/components/main-page.tsx | 383 ++++++++---------- .../staking/components/validator-page.tsx | 17 +- .../staking/components/validator-row.tsx | 77 ++++ src/features/staking/context/actions.ts | 12 +- src/features/staking/context/selectors.ts | 12 + src/features/staking/lib/core/accounts.ts | 7 + src/features/staking/lib/core/base.ts | 148 +------ src/features/staking/lib/core/tx.ts | 145 +++++++ 8 files changed, 443 insertions(+), 358 deletions(-) create mode 100644 src/features/staking/components/validator-row.tsx create mode 100644 src/features/staking/lib/core/accounts.ts create mode 100644 src/features/staking/lib/core/tx.ts diff --git a/src/features/staking/components/main-page.tsx b/src/features/staking/components/main-page.tsx index d6d234e..8aa810c 100644 --- a/src/features/staking/components/main-page.tsx +++ b/src/features/staking/components/main-page.tsx @@ -2,9 +2,10 @@ import { useAbstraxionSigningClient, useModal } from "@burnt-labs/abstraxion"; import { Button } from "@burnt-labs/ui"; +import type { Coin } from "@cosmjs/stargate"; import type { Validator } from "cosmjs-types/cosmos/staking/v1beta1/staking"; import Link from "next/link"; -import { memo, useEffect, useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { toast } from "react-toastify"; import { @@ -13,68 +14,13 @@ import { unstakeValidatorAction, } from "../context/actions"; import { useStaking } from "../context/hooks"; -import { getTotalDelegation } from "../context/selectors"; -import type { StakingState } from "../context/state"; -import type { StakeAddresses } from "../lib/core/base"; -import { chainId } from "../lib/core/constants"; +import { getTotalDelegation, getTotalUnbonding } from "../context/selectors"; +import { getAccountExplorerLink } from "../lib/core/accounts"; +import { sumAllCoins } from "../lib/core/coins"; +import type { StakeAddresses } from "../lib/core/tx"; import { formatCoin } from "../lib/formatters"; -import { keybaseClient } from "../lib/utils/keybase-client"; import DebugAccount from "./debug-account"; - -type ValidatorItemProps = { - disabled?: boolean; - onStake: () => void; - validator: NonNullable["items"][number]; -}; - -const ValidatorRow = ({ disabled, onStake, validator }: ValidatorItemProps) => { - const { website } = validator.description; - const [logo, setLogo] = useState(null); - - const { identity } = validator.description; - - useEffect(() => { - (async () => { - if (identity) { - const logoResponse = await keybaseClient.getIdentityLogo(identity); - - setLogo(logoResponse); - } - })(); - }, [identity]); - - return ( -
- {logo && ( -
- Validator logo -
- )} - -
- {validator.description.moniker} -
-
{validator.operatorAddress}
- -
- -
- {website && ( -
- - {website} - -
- )} -
- ); -}; +import ValidatorRow from "./validator-row"; function StakingPage() { const { account, staking } = useStaking(); @@ -100,6 +46,11 @@ function StakingPage() { ); const totalDelegation = getTotalDelegation(staking.state); + const totalUnbonding = getTotalUnbonding(staking.state); + + const totalOwned = sumAllCoins( + [tokens, totalUnbonding, totalDelegation].filter((c): c is Coin => !!c), + ); return ( <> @@ -127,167 +78,187 @@ function StakingPage() { Total delegation: {formatCoin(totalDelegation)} )} - - {isInfoLoading &&
Loading ...
} - {!!delegations?.items.length && ( -
-
Delegations:
- {delegations.items.map((delegation) => { - const validator = validatorsMap[delegation.validatorAddress]; - const moniker = validator?.description.moniker; - - return ( -
-
Delegated: {formatCoin(delegation.balance)}
- {delegation.rewards && ( -
Rewards: {formatCoin(delegation.rewards)}
- )} -
Validator: {moniker || delegation.validatorAddress}
-
- - {delegation.rewards && ( - - )} -
-
- ); - })} -
- )} - {!!unbondings?.items.length && ( -
-
Unbondings:
+ {totalUnbonding && (
- {unbondings?.items.map((unbondingItem) => { - const validator = validatorsMap[unbondingItem.validator]; - - return ( -
-
- Unbonding tokens: {formatCoin(unbondingItem.balance)} -
-
- Completed by:{" "} - {new Date(unbondingItem.completionTime).toString()} -
-
- Validator:{" "} - {validator?.description.moniker || unbondingItem.validator} -
-
- ); - })} + Total unbonding: {formatCoin(totalUnbonding)}
-
- )} + )} + {totalOwned && ( +
+ Total owned: {formatCoin(totalOwned)} +
+ )} + + {isInfoLoading &&
Loading ...
}
- {!!validators?.items.length && ( -
-
Validators:
-
- {validators.items.map((validator) => ( - { - if (!client) return; +
+ {!!validators?.items.length && ( +
+
Validators:
+
+ {validators.items.map((validator) => ( + { + if (!client) return; - setIsLoading(true); + setIsLoading(true); - const addresses: StakeAddresses = { - delegator: account.bech32Address, - validator: validator.operatorAddress, - }; + const addresses: StakeAddresses = { + delegator: account.bech32Address, + validator: validator.operatorAddress, + }; - stakeValidatorAction(addresses, client, staking) - .then(() => { - toast("Staking successful", { - type: "success", - }); - }) - .catch(() => { - toast("Staking error", { - type: "error", + stakeValidatorAction(addresses, client, staking) + .then(() => { + toast("Staking successful", { + type: "success", + }); + }) + .catch(() => { + toast("Staking error", { + type: "error", + }); + }) + .finally(() => { + setIsLoading(false); }); - }) - .finally(() => { - setIsLoading(false); - }); - }} - validator={validator} - /> - ))} + }} + staking={staking} + validator={validator} + /> + ))} +
+ )} +
+ {!!delegations?.items.length && ( +
+
Delegations:
+ {delegations.items.map((delegation) => { + const validator = validatorsMap[delegation.validatorAddress]; + const moniker = validator?.description.moniker; + + return ( +
+
Delegated: {formatCoin(delegation.balance)}
+ {delegation.rewards && ( +
Rewards: {formatCoin(delegation.rewards)}
+ )} +
+ Validator: {moniker || delegation.validatorAddress} +
+
+ + {delegation.rewards && ( + + )} +
+
+ ); + })} +
+ )} + {!!unbondings?.items.length && ( +
+
Unbondings:
+
+ {unbondings?.items.map((unbondingItem) => { + const validator = validatorsMap[unbondingItem.validator]; + + return ( +
+
+ Unbonding tokens: {formatCoin(unbondingItem.balance)} +
+
+ Completed by:{" "} + {new Date(unbondingItem.completionTime).toString()} +
+
+ Validator:{" "} + {validator?.description.moniker || + unbondingItem.validator} +
+
+ ); + })} +
+
+ )}
- )} +
); } diff --git a/src/features/staking/components/validator-page.tsx b/src/features/staking/components/validator-page.tsx index 208b863..6b566a3 100644 --- a/src/features/staking/components/validator-page.tsx +++ b/src/features/staking/components/validator-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { Button } from "@burnt-labs/ui"; import { BondStatus } from "cosmjs-types/cosmos/staking/v1beta1/staking"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; @@ -8,6 +9,7 @@ import { useEffect, useState } from "react"; import { getValidatorDetailsAction } from "../context/actions"; import { useStaking } from "../context/hooks"; import { getVotingPowerPerc } from "../context/selectors"; +import { getValidatorExplorerLink } from "../lib/core/accounts"; import { formatCommission, formatVotingPowerPerc } from "../lib/formatters"; import { keybaseClient } from "../lib/utils/keybase-client"; @@ -63,10 +65,19 @@ export default function ValidatorPage() {
)}
{validatorDetails.description.moniker}
+
+ + + +
{validatorDetails.description.details}
-
{validatorDetails.description.identity}
+
Identity: {validatorDetails.description.identity}
{validatorDetails.description.securityContact}
{validatorDetails.description.website}
+
Pub key: {validatorDetails.consensusPubkey?.value}
Commission:{" "} {formatCommission(validatorDetails.commission.commissionRates.rate)} @@ -89,7 +100,9 @@ export default function ValidatorPage() { : validatorDetails.status}
{votingPowerPercStr &&
Voting Power: {votingPowerPercStr}
} - Back + + +
); } diff --git a/src/features/staking/components/validator-row.tsx b/src/features/staking/components/validator-row.tsx new file mode 100644 index 0000000..bc422f9 --- /dev/null +++ b/src/features/staking/components/validator-row.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Button } from "@burnt-labs/ui"; +import Link from "next/link"; +import { memo, useEffect, useState } from "react"; + +import { getVotingPowerPerc } from "../context/selectors"; +import type { StakingContextType, StakingState } from "../context/state"; +import { formatVotingPowerPerc } from "../lib/formatters"; +import { keybaseClient } from "../lib/utils/keybase-client"; + +type ValidatorItemProps = { + disabled?: boolean; + onStake: () => void; + staking: StakingContextType; + validator: NonNullable["items"][number]; +}; + +const ValidatorRow = ({ + disabled, + onStake, + staking, + validator, +}: ValidatorItemProps) => { + const { website } = validator.description; + const [logo, setLogo] = useState(null); + + const { identity } = validator.description; + + useEffect(() => { + (async () => { + if (identity) { + const logoResponse = await keybaseClient.getIdentityLogo(identity); + + setLogo(logoResponse); + } + })(); + }, [identity]); + + const votingPowerPerc = getVotingPowerPerc(validator?.tokens, staking.state); + const votingPowerPercStr = formatVotingPowerPerc(votingPowerPerc); + + return ( +
+ {logo && ( +
+ Validator logo +
+ )} + +
+ {validator.description.moniker} +
+
{validator.operatorAddress}
+ + {votingPowerPerc &&
Voting power: {votingPowerPercStr}
} +
+ +
+ {website && ( +
+ + {website} + +
+ )} +
+ ); +}; + +export default memo(ValidatorRow); diff --git a/src/features/staking/context/actions.ts b/src/features/staking/context/actions.ts index 8834378..e8ffbe8 100644 --- a/src/features/staking/context/actions.ts +++ b/src/features/staking/context/actions.ts @@ -1,6 +1,4 @@ -import type { StakeAddresses } from "../lib/core/base"; import { - claimRewards, getBalance, getDelegations, getPool, @@ -8,12 +6,16 @@ import { getUnbondingDelegations, getValidatorDetails, getValidatorsList, - setRedelegate, - stakeAmount, - unstakeAmount, } from "../lib/core/base"; import type { AbstraxionSigningClient } from "../lib/core/client"; import { sumAllCoins } from "../lib/core/coins"; +import type { StakeAddresses } from "../lib/core/tx"; +import { + claimRewards, + setRedelegate, + stakeAmount, + unstakeAmount, +} from "../lib/core/tx"; import { addDelegations, addUnbondings, diff --git a/src/features/staking/context/selectors.ts b/src/features/staking/context/selectors.ts index 58effe0..83397cd 100644 --- a/src/features/staking/context/selectors.ts +++ b/src/features/staking/context/selectors.ts @@ -15,6 +15,18 @@ export const getTotalDelegation = (state: StakingState) => { return sumAllCoins(delegationCoins); }; +export const getTotalUnbonding = (state: StakingState) => { + const { unbondings } = state; + + if (!unbondings?.items.length) { + return null; + } + + const unbondingCoins = unbondings.items.map((d) => d.balance); + + return sumAllCoins(unbondingCoins); +}; + export const getVotingPowerPerc = ( validatorTokens: string, state: StakingState, diff --git a/src/features/staking/lib/core/accounts.ts b/src/features/staking/lib/core/accounts.ts new file mode 100644 index 0000000..665b266 --- /dev/null +++ b/src/features/staking/lib/core/accounts.ts @@ -0,0 +1,7 @@ +import { chainId } from "./constants"; + +export const getAccountExplorerLink = (address: string) => + `https://explorer.burnt.com/${chainId}/account/${address}`; + +export const getValidatorExplorerLink = (address: string) => + `https://explorer.burnt.com/${chainId}/staking/${address}`; diff --git a/src/features/staking/lib/core/base.ts b/src/features/staking/lib/core/base.ts index c46437c..b4d60af 100644 --- a/src/features/staking/lib/core/base.ts +++ b/src/features/staking/lib/core/base.ts @@ -1,30 +1,14 @@ import { StargateClient } from "@cosmjs/stargate"; -import type { - Coin, - DeliverTxResponse, - MsgBeginRedelegateEncodeObject, - MsgDelegateEncodeObject, - MsgUndelegateEncodeObject, - MsgWithdrawDelegatorRewardEncodeObject, -} from "@cosmjs/stargate"; import BigNumber from "bignumber.js"; -import { MsgWithdrawDelegatorReward } from "cosmjs-types/cosmos/distribution/v1beta1/tx"; import type { QueryValidatorsResponse } from "cosmjs-types/cosmos/staking/v1beta1/query"; import type { Pool, Validator, } from "cosmjs-types/cosmos/staking/v1beta1/staking"; -import { - MsgBeginRedelegate, - MsgDelegate, - MsgUndelegate, -} from "cosmjs-types/cosmos/staking/v1beta1/tx"; -import type { AbstraxionSigningClient } from "./client"; import { getStakingQueryClient } from "./client"; import { normaliseCoin } from "./coins"; import { rpcEndpoint } from "./constants"; -import { getCosmosFee } from "./fee"; let validatorsRequest: null | Promise = null; @@ -54,7 +38,9 @@ export const getValidatorDetails = async (address: string) => { const queryClient = await getStakingQueryClient(); const promise = queryClient.staking.validator(address).then((resp) => { - validatorDetailsRequest = null; + if (validatorDetailsRequest?.[0] === address) { + validatorDetailsRequest = null; + } return resp.validator; }); @@ -89,26 +75,6 @@ export const getBalance = async (address: string) => { return await client.getBalance(address, "uxion"); }; -const getTxVerifier = (eventType: string) => (result: DeliverTxResponse) => { - // @TODO - // eslint-disable-next-line no-console - console.log("debug: base.ts: result", result); - - if (!result.events.find((e) => e.type === eventType)) { - console.error(result); - throw new Error("Out of gas"); - } - - return result; -}; - -const handleTxError = (err: unknown) => { - // eslint-disable-next-line no-console - console.error(err); - - throw err; -}; - export const getDelegations = async (address: string) => { const queryClient = await getStakingQueryClient(); @@ -153,111 +119,3 @@ export const getRewards = async (address: string, validatorAddress: string) => { })) .map((r) => normaliseCoin(r)); }; - -export type StakeAddresses = { - delegator: string; - validator: string; -}; - -export const stakeAmount = async ( - addresses: StakeAddresses, - client: NonNullable, - amount: Coin, -) => { - const msg = MsgDelegate.fromPartial({ - amount, - delegatorAddress: addresses.delegator, - validatorAddress: addresses.validator, - }); - - const messageWrapper: MsgDelegateEncodeObject = { - typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", - value: msg, - }; - - const fee = await getCosmosFee({ - address: addresses.delegator, - msgs: [messageWrapper], - }); - - return await client - .signAndBroadcast(addresses.delegator, [messageWrapper], fee) - .then(getTxVerifier("delegate")) - .catch(handleTxError); -}; - -export const unstakeAmount = async ( - addresses: StakeAddresses, - client: NonNullable, - amount: Coin, -) => { - const msg = MsgUndelegate.fromPartial({ - amount, - delegatorAddress: addresses.delegator, - validatorAddress: addresses.validator, - }); - - const messageWrapper: MsgUndelegateEncodeObject = { - typeUrl: "/cosmos.staking.v1beta1.MsgUndelegate", - value: msg, - }; - - const fee = await getCosmosFee({ - address: addresses.delegator, - msgs: [messageWrapper], - }); - - return await client - .signAndBroadcast(addresses.delegator, [messageWrapper], fee) - .then(getTxVerifier("unbond")) - .catch(handleTxError); -}; - -export const claimRewards = async ( - addresses: StakeAddresses, - client: NonNullable, -) => { - const msg = MsgWithdrawDelegatorReward.fromPartial({ - delegatorAddress: addresses.delegator, - validatorAddress: addresses.validator, - }); - - const messageWrapper = [ - { - typeUrl: "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", - value: msg, - } satisfies MsgWithdrawDelegatorRewardEncodeObject, - ]; - - const fee = await getCosmosFee({ - address: addresses.delegator, - msgs: messageWrapper, - }); - - return await client - .signAndBroadcast(addresses.delegator, messageWrapper, fee) - .then(getTxVerifier("withdraw_rewards")) - .catch(handleTxError); -}; - -// @TODO: Pass the target delegator -export const setRedelegate = async ( - delegatorAddress: string, - client: NonNullable, -) => { - const msg = MsgBeginRedelegate.fromPartial({ - delegatorAddress, - }); - - const messageWrapper: MsgBeginRedelegateEncodeObject = { - typeUrl: "/cosmos.staking.v1beta1.MsgBeginRedelegate", - value: msg, - }; - - const fee = await getCosmosFee({ - address: delegatorAddress, - msgs: [messageWrapper], - }); - - return await client.signAndBroadcast(delegatorAddress, [messageWrapper], fee); -}; diff --git a/src/features/staking/lib/core/tx.ts b/src/features/staking/lib/core/tx.ts new file mode 100644 index 0000000..b6c1eb6 --- /dev/null +++ b/src/features/staking/lib/core/tx.ts @@ -0,0 +1,145 @@ +import type { + Coin, + DeliverTxResponse, + MsgBeginRedelegateEncodeObject, + MsgDelegateEncodeObject, + MsgUndelegateEncodeObject, + MsgWithdrawDelegatorRewardEncodeObject, +} from "@cosmjs/stargate"; +import { MsgWithdrawDelegatorReward } from "cosmjs-types/cosmos/distribution/v1beta1/tx"; +import { + MsgBeginRedelegate, + MsgDelegate, + MsgUndelegate, +} from "cosmjs-types/cosmos/staking/v1beta1/tx"; + +import type { AbstraxionSigningClient } from "./client"; +import { getCosmosFee } from "./fee"; + +const getTxVerifier = (eventType: string) => (result: DeliverTxResponse) => { + // @TODO + // eslint-disable-next-line no-console + console.log("debug: base.ts: result", result); + + if (!result.events.find((e) => e.type === eventType)) { + console.error(result); + throw new Error("Out of gas"); + } + + return result; +}; + +const handleTxError = (err: unknown) => { + // eslint-disable-next-line no-console + console.error(err); + + throw err; +}; + +export type StakeAddresses = { + delegator: string; + validator: string; +}; + +export const stakeAmount = async ( + addresses: StakeAddresses, + client: NonNullable, + amount: Coin, +) => { + const msg = MsgDelegate.fromPartial({ + amount, + delegatorAddress: addresses.delegator, + validatorAddress: addresses.validator, + }); + + const messageWrapper: MsgDelegateEncodeObject = { + typeUrl: "/cosmos.staking.v1beta1.MsgDelegate", + value: msg, + }; + + const fee = await getCosmosFee({ + address: addresses.delegator, + msgs: [messageWrapper], + }); + + return await client + .signAndBroadcast(addresses.delegator, [messageWrapper], fee) + .then(getTxVerifier("delegate")) + .catch(handleTxError); +}; + +export const unstakeAmount = async ( + addresses: StakeAddresses, + client: NonNullable, + amount: Coin, +) => { + const msg = MsgUndelegate.fromPartial({ + amount, + delegatorAddress: addresses.delegator, + validatorAddress: addresses.validator, + }); + + const messageWrapper: MsgUndelegateEncodeObject = { + typeUrl: "/cosmos.staking.v1beta1.MsgUndelegate", + value: msg, + }; + + const fee = await getCosmosFee({ + address: addresses.delegator, + msgs: [messageWrapper], + }); + + return await client + .signAndBroadcast(addresses.delegator, [messageWrapper], fee) + .then(getTxVerifier("unbond")) + .catch(handleTxError); +}; + +export const claimRewards = async ( + addresses: StakeAddresses, + client: NonNullable, +) => { + const msg = MsgWithdrawDelegatorReward.fromPartial({ + delegatorAddress: addresses.delegator, + validatorAddress: addresses.validator, + }); + + const messageWrapper = [ + { + typeUrl: "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + value: msg, + } satisfies MsgWithdrawDelegatorRewardEncodeObject, + ]; + + const fee = await getCosmosFee({ + address: addresses.delegator, + msgs: messageWrapper, + }); + + return await client + .signAndBroadcast(addresses.delegator, messageWrapper, fee) + .then(getTxVerifier("withdraw_rewards")) + .catch(handleTxError); +}; + +// @TODO: Pass the target delegator +export const setRedelegate = async ( + delegatorAddress: string, + client: NonNullable, +) => { + const msg = MsgBeginRedelegate.fromPartial({ + delegatorAddress, + }); + + const messageWrapper: MsgBeginRedelegateEncodeObject = { + typeUrl: "/cosmos.staking.v1beta1.MsgBeginRedelegate", + value: msg, + }; + + const fee = await getCosmosFee({ + address: delegatorAddress, + msgs: [messageWrapper], + }); + + return await client.signAndBroadcast(delegatorAddress, [messageWrapper], fee); +};