diff --git a/src/hooks/staking/staking.tsx b/src/hooks/staking/staking.tsx index d605219c..3579a40f 100644 --- a/src/hooks/staking/staking.tsx +++ b/src/hooks/staking/staking.tsx @@ -56,6 +56,12 @@ export function useStakingPallet() { const [estimatedRewards, setEstimatedRewards] = useState('0'); const [fees, setFees] = useState(defaultTransactionFees); + const fetchEstimatedReward = async () => { + if (!api || !walletAccount) return '0'; + const formattedAddr = ss58Format ? getAddressForFormat(walletAccount.address, ss58Format) : walletAccount.address; + return (await api.query.parachainStaking.rewards(formattedAddr)).toString(); + }; + useEffect(() => { if (!api) { return; @@ -84,12 +90,6 @@ export function useStakingPallet() { return inflationInfo.toHuman() as unknown as ParachainStakingInflationInflationInfo; }; - const fetchEstimatedReward = async () => { - if (!walletAccount) return '0'; - const formattedAddr = ss58Format ? getAddressForFormat(walletAccount.address, ss58Format) : walletAccount.address; - return (await api.query.parachainStaking.rewards(formattedAddr)).toString(); - }; - const fetchFees = async () => { const dummyAddress = '5D4tzEZy9XeNSwsAXgtZrRrs1bTfpPTWGqwb1PwCYjRTKYYS'; const sender = dummyAddress; @@ -128,6 +128,9 @@ export function useStakingPallet() { minDelegatorStake, estimatedRewards, fees, + refreshRewards() { + fetchEstimatedReward().then((reward) => setEstimatedRewards(reward)); + }, async getTransactionFee(extrinsic: SubmittableExtrinsic) { if (!api || !extrinsic.hasPaymentInfo) { return new Big(0); @@ -174,6 +177,16 @@ export function useStakingPallet() { return api.tx.parachainStaking.leaveDelegators(); }, + createUpdateDelegatorRewardsExtrinsic() { + if (!api) { + return undefined; + } + const txs = [ + api.tx.parachainStaking?.incrementDelegatorRewards(), + api.tx.parachainStaking?.incrementCollatorRewards(), + ]; + return api.tx.utility.batch(txs); + }, }; }, [api, candidates, inflationInfo, fees, minDelegatorStake, estimatedRewards]); diff --git a/src/pages/collators/CollatorRewards.tsx b/src/pages/collators/CollatorRewards.tsx new file mode 100644 index 00000000..f6aba88e --- /dev/null +++ b/src/pages/collators/CollatorRewards.tsx @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from 'react-daisyui'; +import { toast } from 'react-toastify'; +import { useGlobalState } from '../../GlobalStateProvider'; +import { useNodeInfoState } from '../../NodeInfoProvider'; +import RewardsIcon from '../../assets/collators-rewards-icon'; +import StakedIcon from '../../assets/collators-staked-icon'; +import { getAddressForFormat } from '../../helpers/addressFormatter'; +import { nativeToFormat } from '../../helpers/parseNumbers'; +import { getErrors } from '../../helpers/substrate'; +import { useStakingPallet } from '../../hooks/staking/staking'; +import { UserStaking } from './columns'; +import ClaimRewardsDialog from './dialogs/ClaimRewardsDialog'; + +function CollatorRewards() { + const [userAvailableBalance, setUserAvailableBalance] = useState('0.00'); + const [userStaking, setUserStaking] = useState(); + const [claimDialogOpen, setClaimDialogOpen] = useState(false); + const [submissionPending, setSubmissionPending] = useState(false); + + const { api, tokenSymbol, ss58Format } = useNodeInfoState().state; + const { walletAccount } = useGlobalState(); + const { candidates, estimatedRewards, refreshRewards, createUpdateDelegatorRewardsExtrinsic } = useStakingPallet(); + + const userAccountAddress = useMemo(() => { + return walletAccount && ss58Format ? getAddressForFormat(walletAccount?.address, ss58Format) : ''; + }, [walletAccount, ss58Format]); + + useMemo(() => { + setUserStaking(undefined); + return candidates?.forEach((candidate) => { + const isDelegator = candidate.delegators.find((delegator) => delegator.owner === userAccountAddress); + if (isDelegator) { + setUserStaking({ + candidateId: candidate.id, + amount: isDelegator.amount, + }); + } + }); + }, [candidates, userAccountAddress, setUserStaking]); + + useEffect(() => { + const fetchAvailableBalance = async () => { + if (!api || !walletAccount) { + return '0'; + } + const { data: balance } = await api.query.system.account(walletAccount?.address); + return balance.free.sub(balance.miscFrozen).toString(); + }; + + fetchAvailableBalance().then((balance) => setUserAvailableBalance(balance)); + }, [api, walletAccount]); + + const updateRewardsExtrinsic = useMemo(() => { + if (!api) { + return undefined; + } + + return createUpdateDelegatorRewardsExtrinsic(); + }, [api, createUpdateDelegatorRewardsExtrinsic]); + + const submitUpdateExtrinsic = useCallback(() => { + if (!updateRewardsExtrinsic || !api || !walletAccount) { + return; + } + setSubmissionPending(true); + updateRewardsExtrinsic + .signAndSend(walletAccount.address, { signer: walletAccount.signer as any }, (result) => { + const { status, events } = result; + const errors = getErrors(events, api); + if (status.isInBlock) { + if (errors.length > 0) { + const errorMessage = `Transaction failed with errors: ${errors.join('\n')}`; + console.error(errorMessage); + toast(errorMessage, { type: 'error' }); + } + } else if (status.isFinalized) { + setSubmissionPending(false); + refreshRewards(); + if (errors.length === 0) { + toast('Delegator rewards updated', { type: 'success' }); + } + } + }) + .catch((error) => { + toast('Transaction submission failed: ' + error.toString(), { + type: 'error', + }); + setSubmissionPending(false); + }); + }, [api, setSubmissionPending, updateRewardsExtrinsic, walletAccount]); + + return ( + <> +
+
+
+

Staking

+
+
+ +
+
+

{nativeToFormat(userStaking?.amount || '0.00', tokenSymbol)}

+

My Staking

+
+
+

{nativeToFormat(userAvailableBalance, tokenSymbol)}

+

Free balance

+
+
+ +
+
+
+
+
+
+

Staking Rewards

+
+
+ +
+
+

{nativeToFormat(estimatedRewards, tokenSymbol)}

+

Estimated reward

+
+
+ + +
+
+
+
+
+ setClaimDialogOpen(false)} + /> + + ); +} + +export default CollatorRewards; diff --git a/src/pages/collators/Collators.tsx b/src/pages/collators/Collators.tsx index bc65122e..85347823 100644 --- a/src/pages/collators/Collators.tsx +++ b/src/pages/collators/Collators.tsx @@ -1,198 +1,11 @@ -import { useEffect, useMemo, useState } from 'preact/hooks'; -import { useGlobalState } from '../../GlobalStateProvider'; -import { useNodeInfoState } from '../../NodeInfoProvider'; -import RewardsIcon from '../../assets/collators-rewards-icon'; -import StakedIcon from '../../assets/collators-staked-icon'; -import { nativeToFormat } from '../../helpers/parseNumbers'; - -import Table from '../../components/Table'; -import { getAddressForFormat } from '../../helpers/addressFormatter'; -import { ParachainStakingCandidate, useStakingPallet } from '../../hooks/staking/staking'; -import { PalletIdentityInfo, useIdentityPallet } from '../../hooks/useIdentityPallet'; -import { - TCollator, - UserStaking, - actionsColumn, - apyColumn, - delegatorsColumn, - myStakedColumn, - nameColumn, - stakedColumn, -} from './columns'; -import ClaimRewardsDialog from './dialogs/ClaimRewardsDialog'; -import ExecuteDelegationDialogs from './dialogs/ExecuteDelegationDialogs'; +import CollatorRewards from './CollatorRewards'; +import CollatorsTable from './CollatorsTable'; function Collators() { - const { api, tokenSymbol, ss58Format } = useNodeInfoState().state; - const { walletAccount } = useGlobalState(); - - const { candidates, inflationInfo, estimatedRewards } = useStakingPallet(); - const { identityOf } = useIdentityPallet(); - - // Holds the candidate for which the delegation modal is to be shown - const [selectedCandidate, setSelectedCandidate] = useState(undefined); - - const [userAvailableBalance, setUserAvailableBalance] = useState('0.00'); - const [userStaking, setUserStaking] = useState(); - const [claimDialogOpen, setClaimDialogOpen] = useState(false); - const [unbonding, setUnbonding] = useState(false); - const [data, setData] = useState(); - - const userAccountAddress = useMemo(() => { - return walletAccount && ss58Format ? getAddressForFormat(walletAccount?.address, ss58Format) : ''; - }, [walletAccount, ss58Format]); - - useMemo(() => { - setUserStaking(undefined); - return candidates?.forEach((candidate) => { - const isDelegator = candidate.delegators.find((delegator) => delegator.owner === userAccountAddress); - if (isDelegator) { - setUserStaking({ - candidateId: candidate.id, - amount: isDelegator.amount, - }); - } - }); - }, [candidates, userAccountAddress, setUserStaking]); - - useEffect(() => { - const fetchAvailableBalance = async () => { - if (!api || !walletAccount) { - return '0'; - } - const { data: balance } = await api.query.system.account(walletAccount?.address); - return balance.free.sub(balance.miscFrozen).toString(); - }; - - fetchAvailableBalance().then((balance) => setUserAvailableBalance(balance)); - }, [api, walletAccount]); - - useEffect(() => { - const identitiesPrefetch = async (candidatesArray: ParachainStakingCandidate[]) => { - const m: Map = new Map(); - for (let i = 0; i < candidatesArray.length; i++) { - const c = candidatesArray[i]; - m.set(c.id, await identityOf(c.id)); - } - return m; - }; - - const decorateCandidates = ( - candidatesArray: ParachainStakingCandidate[], - identities: Map, - ) => { - return candidatesArray?.map((candidate) => ({ - candidate: candidate, - collator: candidate.id, - identityInfo: identities.get(candidate.id), - totalStaked: nativeToFormat(candidate.total, tokenSymbol), - delegators: candidate.delegators.length, - apy: inflationInfo?.delegator.rewardRate.annual || '0.00%', - })); - }; - - if (candidates) { - identitiesPrefetch(candidates).then((identitiesMap) => { - const d = decorateCandidates(candidates, identitiesMap); - setData(d); - }); - } - }, [candidates, inflationInfo?.delegator.rewardRate.annual, tokenSymbol, identityOf]); - - const columns = useMemo(() => { - return [ - nameColumn, - stakedColumn, - delegatorsColumn, - apyColumn, - myStakedColumn({ userAccountAddress, tokenSymbol }), - actionsColumn({ - userAccountAddress, - walletAccount, - userStaking, - setSelectedCandidate, - setUnbonding, - }), - ]; - }, [tokenSymbol, userAccountAddress, userStaking, walletAccount, setUnbonding]); - return ( -
-
-
-
-

Staking

-
-
- -
-
-

{nativeToFormat(userStaking?.amount || '0.00', tokenSymbol)}

-

My Staking

-
-
-

{nativeToFormat(userAvailableBalance, tokenSymbol)}

-

Free balance

-
-
- -
-
-
-
-
-
-

Staking Rewards

-
-
- -
-
-

{nativeToFormat(estimatedRewards, tokenSymbol)}

-

Estimated reward

-
-
- -
-
-
-
-
- { - setSelectedCandidate(undefined); - setUnbonding(false); - }} - /> - setClaimDialogOpen(false)} - /> - +
+ +
); } diff --git a/src/pages/collators/CollatorsTable.tsx b/src/pages/collators/CollatorsTable.tsx new file mode 100644 index 00000000..33cb1bb2 --- /dev/null +++ b/src/pages/collators/CollatorsTable.tsx @@ -0,0 +1,140 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useGlobalState } from '../../GlobalStateProvider'; +import { nativeToFormat } from '../../helpers/parseNumbers'; +import { useNodeInfoState } from '../../NodeInfoProvider'; + +import Table from '../../components/Table'; +import { getAddressForFormat } from '../../helpers/addressFormatter'; +import { ParachainStakingCandidate, useStakingPallet } from '../../hooks/staking/staking'; +import { + actionsColumn, + apyColumn, + delegatorsColumn, + myStakedColumn, + nameColumn, + stakedColumn, + TCollator, + UserStaking, +} from './columns'; +import ExecuteDelegationDialogs from './dialogs/ExecuteDelegationDialogs'; +import { PalletIdentityInfo, useIdentityPallet } from '../../hooks/useIdentityPallet'; + +function CollatorsTable() { + const { api, tokenSymbol, ss58Format } = useNodeInfoState().state; + const { walletAccount } = useGlobalState(); + const { candidates, inflationInfo } = useStakingPallet(); + const { identityOf } = useIdentityPallet(); + + // Holds the candidate for which the delegation modal is to be shown + const [selectedCandidate, setSelectedCandidate] = useState(undefined); + const [userAvailableBalance, setUserAvailableBalance] = useState('0.00'); + const [userStaking, setUserStaking] = useState(); + const [unbonding, setUnbonding] = useState(false); + const [data, setData] = useState(); + + const userAccountAddress = useMemo(() => { + return walletAccount && ss58Format ? getAddressForFormat(walletAccount?.address, ss58Format) : ''; + }, [walletAccount, ss58Format]); + + useMemo(() => { + setUserStaking(undefined); + return candidates?.forEach((candidate) => { + const isDelegator = candidate.delegators.find((delegator) => delegator.owner === userAccountAddress); + if (isDelegator) { + setUserStaking({ + candidateId: candidate.id, + amount: isDelegator.amount, + }); + } + }); + }, [candidates, userAccountAddress, setUserStaking]); + + useEffect(() => { + const fetchAvailableBalance = async () => { + if (!api || !walletAccount) { + return '0'; + } + const { data: balance } = await api.query.system.account(walletAccount?.address); + return balance.free.sub(balance.miscFrozen).toString(); + }; + + fetchAvailableBalance().then((balance) => setUserAvailableBalance(balance)); + }, [api, walletAccount]); + + useEffect(() => { + const identitiesPrefetch = async (candidatesArray: any) => { + const m: Map = new Map(); + for (let i = 0; i < candidatesArray.length; i++) { + const c = candidatesArray[i]; + m.set(c.id, await identityOf(c.id)); + } + return m; + }; + + const decorateCandidates = ( + candidatesArray: ParachainStakingCandidate[], + identities: Map, + ) => { + return candidatesArray?.map((candidate) => ({ + candidate: candidate, + collator: candidate.id, + identityInfo: identities.get(candidate.id), + totalStaked: nativeToFormat(candidate.total, tokenSymbol), + delegators: candidate.delegators.length, + apy: inflationInfo?.delegator.rewardRate.annual || '0.00%', + })); + }; + + if (candidates) { + identitiesPrefetch(candidates).then((identitiesMap) => { + const d = decorateCandidates(candidates, identitiesMap); + setData(d); + }); + } + }, [candidates, inflationInfo?.delegator.rewardRate.annual, tokenSymbol, identityOf]); + + const columns = useMemo(() => { + return [ + nameColumn, + stakedColumn, + delegatorsColumn, + apyColumn, + myStakedColumn({ userAccountAddress, tokenSymbol }), + actionsColumn({ + userAccountAddress, + walletAccount, + userStaking, + setSelectedCandidate, + setUnbonding, + }), + ]; + }, [tokenSymbol, userAccountAddress, userStaking, walletAccount, setUnbonding]); + + return ( + <> + { + setSelectedCandidate(undefined); + setUnbonding(false); + }} + /> +
+ + ); +} + +export default CollatorsTable; diff --git a/src/pages/collators/columns.tsx b/src/pages/collators/columns.tsx index 00e09977..6a9eab1b 100644 --- a/src/pages/collators/columns.tsx +++ b/src/pages/collators/columns.tsx @@ -30,8 +30,14 @@ export const nameColumn: ColumnDef = { accessorKey: 'collator', accessorFn: ({ identityInfo }) => identityInfo?.display || '0', cell: ({ row }) => { + const desc = row.original.identityInfo + ? `${row.original.identityInfo.email ? row.original.identityInfo.email + '\n' : ''}` + + `${row.original.identityInfo.riot ? row.original.identityInfo.riot + '\n' : ''}` + + `${row.original.identityInfo.twitter ? row.original.identityInfo.twitter + '\n' : ''}` + + `${row.original.identityInfo.web ? row.original.identityInfo.web + '\n' : ''}` + : ''; return ( -
+
{(row.original.identityInfo ? row.original.identityInfo.display : 'Unknown') + ' |'}