From fd8d226d684ba08d3745ef41aa847bdebdd5c92c Mon Sep 17 00:00:00 2001 From: Yana Date: Fri, 17 Jan 2025 16:19:01 +0200 Subject: [PATCH] Add redeem rewards --- .../redeemRewards/RedeemRewardsModal.tsx | 45 ++++++ .../src/components/staking/StakeModal.tsx | 46 +++--- .../src/components/staking/StakeTable.tsx | 2 - .../staking/SubHeaderRowActions.tsx | 149 +++++++++++++++++- 4 files changed, 216 insertions(+), 26 deletions(-) create mode 100644 explorer-nextjs/src/components/redeemRewards/RedeemRewardsModal.tsx diff --git a/explorer-nextjs/src/components/redeemRewards/RedeemRewardsModal.tsx b/explorer-nextjs/src/components/redeemRewards/RedeemRewardsModal.tsx new file mode 100644 index 0000000000..3c28cfc41a --- /dev/null +++ b/explorer-nextjs/src/components/redeemRewards/RedeemRewardsModal.tsx @@ -0,0 +1,45 @@ +import SimpleModal from "@/components/modal/SimpleModal"; +import { formatBigNum } from "@/utils/formatBigNumbers"; +import { Button, Stack, Typography } from "@mui/material"; + +const RedeemRewardsModal = ({ + totalRewardsAmount, + onRedeem, + onClose, +}: { + totalRewardsAmount: number; + onRedeem: () => Promise; + onClose: () => void; +}) => { + const handleOnClose = () => { + onClose(); + }; + + return ( + onRedeem()} + fullWidth + > + Next + + } + > + + + + {`${formatBigNum(totalRewardsAmount / 1_000_000)} NYM`} + + + + + ); +}; + +export default RedeemRewardsModal; diff --git a/explorer-nextjs/src/components/staking/StakeModal.tsx b/explorer-nextjs/src/components/staking/StakeModal.tsx index 67d039ce0d..f72f2278d6 100644 --- a/explorer-nextjs/src/components/staking/StakeModal.tsx +++ b/explorer-nextjs/src/components/staking/StakeModal.tsx @@ -4,7 +4,7 @@ import { Button, Stack, Typography } from "@mui/material"; import { CurrencyFormField } from "@nymproject/react/currency/CurrencyFormField.js"; import { IdentityKeyFormField } from "@nymproject/react/mixnodes/IdentityKeyFormField.js"; import type { DecCoin } from "@nymproject/types"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import ExplorerListItem from "../list/ListItem"; import stakingSchema, { MIN_AMOUNT_TO_DELEGATE } from "./schemas"; @@ -33,25 +33,35 @@ const StakeModal = ({ const [isValidated, setValidated] = useState(false); const [errorAmount, setErrorAmount] = useState(); - useEffect(() => { - const asyncValidate = async () => { - await stakingSchema - .parseAsync({ amount: amount.amount, balance, nodeId }) - .then(() => { - setValidated(true); - setErrorAmount(undefined); - return true; - }) - .catch((e) => { - console.error(e.errors); - setValidated(false); - setErrorAmount(e.errors[0]?.message); - return false; - }); - }; - asyncValidate(); + const validateAmount = useCallback(async () => { + try { + await stakingSchema.parseAsync({ + amount: amount.amount, + balance, + nodeId, + }); + setValidated(true); + setErrorAmount(undefined); + } catch (e) { + if (e instanceof Error && "errors" in e) { + const validationError = (e as any).errors; // Explicitly cast if necessary + console.error(validationError); + setValidated(false); + setErrorAmount(validationError[0]?.message); + } else { + console.error("Unknown error during validation:", e); + setValidated(false); + setErrorAmount("An unexpected error occurred."); + } + } }, [amount, balance, nodeId]); + useEffect(() => { + if (nodeId) { + validateAmount(); + } + }, [validateAmount, nodeId]); + if (!nodeId) { return null; } diff --git a/explorer-nextjs/src/components/staking/StakeTable.tsx b/explorer-nextjs/src/components/staking/StakeTable.tsx index 16b8d5cbde..c9469a621b 100644 --- a/explorer-nextjs/src/components/staking/StakeTable.tsx +++ b/explorer-nextjs/src/components/staking/StakeTable.tsx @@ -138,8 +138,6 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => { [handleUnstake], ); - console.log("delegations :>> ", delegations); - const columns: MRT_ColumnDef[] = useMemo( () => [ { diff --git a/explorer-nextjs/src/components/staking/SubHeaderRowActions.tsx b/explorer-nextjs/src/components/staking/SubHeaderRowActions.tsx index 19ce209815..52f41df459 100644 --- a/explorer-nextjs/src/components/staking/SubHeaderRowActions.tsx +++ b/explorer-nextjs/src/components/staking/SubHeaderRowActions.tsx @@ -1,22 +1,159 @@ "use client"; +import { useChain } from "@cosmos-kit/react"; + +import type { ObservatoryBalance } from "@/app/api/types"; +import { DATA_OBSERVATORY_BALANCES_URL } from "@/app/api/urls"; +import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "@/config"; import { useNymClient } from "@/hooks/useNymClient"; +import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; +import { GasPrice } from "@cosmjs/stargate"; import { Button, Stack } from "@mui/material"; +import type { Delegation } from "@nymproject/contract-clients/Mixnet.types"; +import { useCallback, useEffect, useState } from "react"; +import Loading from "../loading"; +import InfoModal, { type InfoModalProps } from "../modal/InfoModal"; import { Link } from "../muiLink"; +import RedeemRewardsModal from "../redeemRewards/RedeemRewardsModal"; +import ConnectWallet from "../wallet/ConnectWallet"; + +const fee = { gas: "1000000", amount: [{ amount: "1000000", denom: "unym" }] }; const SubHeaderRowActions = () => { - const { address } = useNymClient(); + const [delegations, setDelegations] = useState([]); + const [totalStakerRewards, setTotalStakerRewards] = useState(0); + const [openRedeemRewardsModal, setOpenRedeemRewardsModal] = + useState(false); - if (!address) { + const [isLoading, setIsLoading] = useState(false); + const [infoModalProps, setInfoModalProps] = useState({ + open: false, + }); + const { address, nymClient } = useNymClient(); + const { getSigningCosmWasmClient } = useChain(COSMOS_KIT_USE_CHAIN); + + useEffect(() => { + if (!nymClient || !address) return; + + const fetchDelegations = async () => { + try { + const data = await nymClient.getDelegatorDelegations({ + delegator: address, + }); + setDelegations(data.delegations); + } catch (error) { + console.error("Failed to fetch delegations:", error); + } + }; + + fetchDelegations(); + + const fetchBalances = async () => { + const data = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, { + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + next: { revalidate: 60 }, + // refresh event list cache at given interval + }); + const balances: ObservatoryBalance = await data.json(); + + return setTotalStakerRewards(balances.rewards.staking_rewards.amount); + }; + + fetchBalances(); + }, [address, nymClient]); + + const handleRedeemRewards = useCallback(async () => { + setIsLoading(true); + setOpenRedeemRewardsModal(false); + + try { + if (!nymClient || !address || !delegations.length) { + throw new Error("Wallet, client, or delegations not available."); + } + + console.log("Preparing to redeem rewards..."); + + const messages = delegations.map((delegation) => { + const nodeId = delegation.node_id; + + // Generate the withdraw message + const tx = { + contractAddress: NYM_MIXNET_CONTRACT, + funds: [], + msg: { + withdraw_delegator_reward: { + node_id: nodeId, + }, + }, + }; + + return tx; // Use the first message generated + }); + + console.log("Messages prepared for multi-signing:", messages); + + const cosmWasmSigningClient = await getSigningCosmWasmClient(); + console.log("Signing client obtained."); + + // Execute all messages in one transaction + const result = await cosmWasmSigningClient.executeMultiple( + address, + messages, + fee, + "Redeeming all rewards", + ); + + console.log("Rewards redeemed successfully:", result); + + // Success state + setIsLoading(false); + setInfoModalProps({ + open: true, + title: "Success", + message: "All rewards have been redeemed successfully!", + onClose: () => setInfoModalProps({ open: false }), + }); + setOpenRedeemRewardsModal(false); + } catch (e) { + console.error("Error redeeming rewards:", e); + setInfoModalProps({ + open: true, + title: "Error", + message: + e instanceof Error + ? e.message + : "An error occurred while redeeming rewards.", + onClose: () => setInfoModalProps({ open: false }), + }); + setIsLoading(false); + } + }, [address, nymClient, delegations, getSigningCosmWasmClient]); + + const handleRedeemRewardsButtonClick = () => { + setOpenRedeemRewardsModal(true); + }; + + if (!address || !nymClient) { return null; } return ( - - - - + + {isLoading && } + {openRedeemRewardsModal && ( + handleRedeemRewards()} + onClose={() => setOpenRedeemRewardsModal(false)} + totalRewardsAmount={totalStakerRewards} + /> + )} + ); };