From 89b2ece6acf7df66eee0d425109659b6070fd813 Mon Sep 17 00:00:00 2001 From: Pedro Rezende Date: Sat, 14 Sep 2024 05:01:23 -0300 Subject: [PATCH] Namadillo: Phase 2 - Claim Rewards + useTransaction (#1100) * feat(namadillo): creating useBalances hook * feat(namadillo): separating colors and other props from tailwind config * feat(components): adding label property to PieChart * feat: refactoring balance props to be reused between staking and overview * feat(namadillo): fixing overview chart * feat(namadillo): adding mocks for useBalance * feat(namadillo): adding tests for NamBalanceContainer * feat(namadillo): adding footer to AccountOverview * feat(namadillo): replacing by Panel component * feat(namadillo): claim rewards component * feat(namadillo): enabling staking rewards * temp: claiming rewards wip * feat(namadillo): adding useTransaction hook and refactoring claim rewards * refactor(namadillo): applying useTransaction to IncrementBonding * feat(namadillo): adding useTransaction on Unstake flow * feat(namadillo): applying useTransaction to redelegate * chore: fixing some merge conflicts * feat(namadillo): applying useTransaction to Withdraw * feat(namadillo): writing tests for StakingRewardsPanel * feat(namadillo): adding claim and stake * feat(namadillo): writing tests for StakingRewards * fix(namadillo): fixing staking rewards not updating --- apps/extension/src/Approvals/Commitment.tsx | 6 + apps/namadillo/jest.config.ts | 10 +- apps/namadillo/package.json | 3 + .../App/AccountOverview/AccountOverview.tsx | 59 ++++-- .../AccountOverview/NamBalanceContainer.tsx | 91 +++++++++ .../__tests__/NamBalanceContainer.test.tsx | 68 +++++++ apps/namadillo/src/App/AppRoutes.tsx | 5 + .../namadillo/src/App/Common/BalanceChart.tsx | 96 ++++++++++ .../src/App/Common/ModalContainer.tsx | 4 +- .../src/App/Common/ModalTransition.tsx | 4 +- .../src/App/Staking/IncrementBonding.tsx | 147 +++++--------- apps/namadillo/src/App/Staking/ReDelegate.tsx | 135 ++++--------- .../src/App/Staking/ReDelegateAssignStake.tsx | 7 +- apps/namadillo/src/App/Staking/Staking.tsx | 8 - .../src/App/Staking/StakingOverview.tsx | 4 +- .../src/App/Staking/StakingRewards.tsx | 135 +++++++++++++ .../src/App/Staking/StakingRewardsPanel.tsx | 62 ++++++ .../src/App/Staking/StakingSummary.tsx | 125 ++++-------- apps/namadillo/src/App/Staking/Unstake.tsx | 141 +++++--------- .../src/App/Staking/WithdrawalButton.tsx | 138 ++++---------- .../Staking/__tests__/StakingRewards.test.tsx | 180 ++++++++++++++++++ .../__tests__/StakingRewardsPanel.test.tsx | 92 +++++++++ .../src/App/Staking/assets/claim-rewards.svg | 1 + apps/namadillo/src/App/Staking/routes.ts | 3 + apps/namadillo/src/atoms/fees/atoms.ts | 2 +- apps/namadillo/src/atoms/staking/atoms.ts | 126 ++++++++---- apps/namadillo/src/atoms/staking/services.ts | 117 ++++++++---- .../src/hooks/__mocks__/mockUseBalance.ts | 21 ++ apps/namadillo/src/hooks/useBalances.ts | 63 ++++++ apps/namadillo/src/hooks/useStakeModule.ts | 12 +- apps/namadillo/src/hooks/useTransaction.tsx | 153 +++++++++++++++ .../src/hooks/useTransactionCallbacks.tsx | 5 + .../src/hooks/useTransactionNotifications.tsx | 28 ++- apps/namadillo/src/test-utils.tsx | 21 ++ apps/namadillo/src/theme.ts | 28 +++ apps/namadillo/src/types.d.ts | 16 +- apps/namadillo/src/types/events.ts | 16 +- apps/namadillo/src/utils/index.ts | 6 + apps/namadillo/tailwind.config.cjs | 27 +-- packages/components/src/PieChart.tsx | 2 + packages/components/src/SkeletonLoading.tsx | 1 + yarn.lock | 28 +++ 42 files changed, 1562 insertions(+), 634 deletions(-) create mode 100644 apps/namadillo/src/App/AccountOverview/NamBalanceContainer.tsx create mode 100644 apps/namadillo/src/App/AccountOverview/__tests__/NamBalanceContainer.test.tsx create mode 100644 apps/namadillo/src/App/Common/BalanceChart.tsx create mode 100644 apps/namadillo/src/App/Staking/StakingRewards.tsx create mode 100644 apps/namadillo/src/App/Staking/StakingRewardsPanel.tsx create mode 100644 apps/namadillo/src/App/Staking/__tests__/StakingRewards.test.tsx create mode 100644 apps/namadillo/src/App/Staking/__tests__/StakingRewardsPanel.test.tsx create mode 100644 apps/namadillo/src/App/Staking/assets/claim-rewards.svg create mode 100644 apps/namadillo/src/hooks/__mocks__/mockUseBalance.ts create mode 100644 apps/namadillo/src/hooks/useBalances.ts create mode 100644 apps/namadillo/src/hooks/useTransaction.tsx create mode 100644 apps/namadillo/src/theme.ts diff --git a/apps/extension/src/Approvals/Commitment.tsx b/apps/extension/src/Approvals/Commitment.tsx index 5e134a401..83c129390 100644 --- a/apps/extension/src/Approvals/Commitment.tsx +++ b/apps/extension/src/Approvals/Commitment.tsx @@ -1,6 +1,7 @@ import { TxType } from "@heliax/namada-sdk/web"; import { BondProps, + ClaimRewardsProps, CommitmentDetailProps, RedelegateProps, RevealPkProps, @@ -103,7 +104,12 @@ const renderContent = (tx: CommitmentDetailProps): ReactNode => { <>Reveal public key for address {formatAddress(revealTx.publicKey)} ); + case TxType.ClaimRewards: + const claimTx = tx as ClaimRewardsProps; + return <>Claiming rewards from {formatAddress(claimTx.validator)}; + // TODO: continue implementing other types in the next phases + default: return <>; } diff --git a/apps/namadillo/jest.config.ts b/apps/namadillo/jest.config.ts index d28872d81..cfaeaadfc 100644 --- a/apps/namadillo/jest.config.ts +++ b/apps/namadillo/jest.config.ts @@ -10,8 +10,12 @@ module.exports = { modulePathIgnorePatterns: ["e2e-tests"], moduleDirectories: ["src", "node_modules"], verbose: true, - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { - prefix: "/src/", - }), + moduleNameMapper: { + ...pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "/src/", + }), + "^.+\\.svg$": "jest-transformer-svg", + "\\.css": "identity-obj-proxy", + }, setupFilesAfterEnv: ["/src/setupTests.ts"], }; diff --git a/apps/namadillo/package.json b/apps/namadillo/package.json index 7a9bfe214..9432e8996 100644 --- a/apps/namadillo/package.json +++ b/apps/namadillo/package.json @@ -57,6 +57,7 @@ "test:watch": "yarn wasm:build:test && yarn jest --watchAll=true", "test:coverage": "yarn wasm:build:test && yarn test --coverage", "test:ci": "jest", + "test:watch-only": "yarn jest --watchAll=true", "e2e-test": "PLAYWRIGHT_BASE_URL=http://localhost:3000 yarn playwright test", "e2e-test:headed": "PLAYWRIGHT_BASE_URL=http://localhost:3000 yarn playwright test --project=chromium --headed", "wasm:build": "node ./scripts/build.js --release", @@ -106,10 +107,12 @@ "eslint-plugin-react-hooks": "^4.6.0", "globals": "^15.9.0", "history": "^5.3.0", + "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-create-mock-instance": "^2.0.0", "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", + "jest-transformer-svg": "^2.0.2", "local-cors-proxy": "^1.1.0", "postcss": "^8.4.32", "release-it": "^17.0.1", diff --git a/apps/namadillo/src/App/AccountOverview/AccountOverview.tsx b/apps/namadillo/src/App/AccountOverview/AccountOverview.tsx index 4a2e7f44b..c336fb169 100644 --- a/apps/namadillo/src/App/AccountOverview/AccountOverview.tsx +++ b/apps/namadillo/src/App/AccountOverview/AccountOverview.tsx @@ -1,34 +1,61 @@ -import { Stack } from "@namada/components"; +import { Panel, Stack } from "@namada/components"; import { Intro } from "App/Common/Intro"; import { PageWithSidebar } from "App/Common/PageWithSidebar"; import MainnetRoadmap from "App/Sidebars/MainnetRoadmap"; -import { namadaExtensionConnectedAtom } from "atoms/settings"; +import { StakingRewardsPanel } from "App/Staking/StakingRewardsPanel"; +import { + applicationFeaturesAtom, + namadaExtensionConnectedAtom, +} from "atoms/settings"; import clsx from "clsx"; import { useAtomValue } from "jotai"; import { AccountBalanceContainer } from "./AccountBalanceContainer"; +import { NamBalanceContainer } from "./NamBalanceContainer"; import { NavigationFooter } from "./NavigationFooter"; export const AccountOverview = (): JSX.Element => { const isConnected = useAtomValue(namadaExtensionConnectedAtom); + const { claimRewardsEnabled, maspEnabled } = useAtomValue( + applicationFeaturesAtom + ); + + const fullView = claimRewardsEnabled || maspEnabled; const fullWidthClassName = clsx({ "col-span-2": !isConnected }); + return ( -
+
{!isConnected && ( -
- -
+
+
+ +
+
)} - {isConnected && ( - - - - + + {isConnected && !fullView && ( +
+ + + + +
+ )} + + {isConnected && fullView && ( +
+
+ + + + + + +
+ + + +
)}
{isConnected && ( diff --git a/apps/namadillo/src/App/AccountOverview/NamBalanceContainer.tsx b/apps/namadillo/src/App/AccountOverview/NamBalanceContainer.tsx new file mode 100644 index 000000000..dbc26977a --- /dev/null +++ b/apps/namadillo/src/App/AccountOverview/NamBalanceContainer.tsx @@ -0,0 +1,91 @@ +import { Stack } from "@namada/components"; +import { AtomErrorBoundary } from "App/Common/AtomErrorBoundary"; +import { BalanceChart } from "App/Common/BalanceChart"; +import { NamCurrency } from "App/Common/NamCurrency"; +import BigNumber from "bignumber.js"; +import { useBalances } from "hooks/useBalances"; +import { colors } from "theme"; + +type NamBalanceListItemProps = { + title: string; + color: string; + amount: BigNumber; +}; + +const NamBalanceListItem = ({ + title, + color, + amount, +}: NamBalanceListItemProps): JSX.Element => { + return ( +
  • + + + {title} + + +
  • + ); +}; + +export const NamBalanceContainer = (): JSX.Element => { + const { + balanceQuery, + stakeQuery, + isLoading, + isSuccess, + availableAmount, + bondedAmount, + shieldedAmount, + unbondedAmount, + withdrawableAmount, + totalAmount, + } = useBalances(); + + return ( +
    + +
    + + + + + + +
    +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/AccountOverview/__tests__/NamBalanceContainer.test.tsx b/apps/namadillo/src/App/AccountOverview/__tests__/NamBalanceContainer.test.tsx new file mode 100644 index 000000000..8a3d60d2c --- /dev/null +++ b/apps/namadillo/src/App/AccountOverview/__tests__/NamBalanceContainer.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import BigNumber from "bignumber.js"; +import { mockUseBalances } from "hooks/__mocks__/mockUseBalance"; +import { AtomWithQueryResult } from "jotai-tanstack-query"; +import { NamBalanceContainer } from "../NamBalanceContainer"; + +jest.mock("hooks/useBalances", () => ({ + useBalances: jest.fn(), +})); + +describe("Component: NamBalanceContainer", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders error boundary when queries fail", () => { + // Check if the error message from AtomErrorBoundary is displayed + const execute = ( + balanceQueryError: boolean, + stakeQueryError: boolean + ): void => { + mockUseBalances({ + balanceQuery: { isError: balanceQueryError } as AtomWithQueryResult, + stakeQuery: { isError: stakeQueryError } as AtomWithQueryResult, + isLoading: false, + isSuccess: false, + }); + render(); + expect(screen.getByText(/Unable to load balances/i)).toBeInTheDocument(); + cleanup(); + jest.clearAllMocks(); + }; + + execute(true, true); + execute(false, true); + execute(true, false); + }); + + test("renders balance items when data is loaded", () => { + mockUseBalances({ + availableAmount: new BigNumber(100), + bondedAmount: new BigNumber(50), + unbondedAmount: new BigNumber(30), + withdrawableAmount: new BigNumber(25), + totalAmount: new BigNumber(200), + }); + + render(); + + // Check if the list items for each balance type are rendered + expect(screen.getByText(/Available NAM/i)).toBeInTheDocument(); + expect(screen.getByText(/Staked NAM/i)).toBeInTheDocument(); + expect(screen.getByText(/Unbonded NAM/i)).toBeInTheDocument(); + + // Check if the amounts are displayed correctly + + // Available: + expect(screen.getByText("100 NAM")).toBeInTheDocument(); + + // Bonded / Staked + expect(screen.getByText("50 NAM")).toBeInTheDocument(); + + // Unbonded + Withdraw + expect(screen.queryByText("30 NAM")).toBeNull(); + expect(screen.queryByText("25 NAM")).toBeNull(); + expect(screen.getByText("55 NAM")).toBeInTheDocument(); + }); +}); diff --git a/apps/namadillo/src/App/AppRoutes.tsx b/apps/namadillo/src/App/AppRoutes.tsx index 6facd7bf2..b404e2a43 100644 --- a/apps/namadillo/src/App/AppRoutes.tsx +++ b/apps/namadillo/src/App/AppRoutes.tsx @@ -19,6 +19,7 @@ import GovernanceRoutes from "./Governance/routes"; import SettingsRoutes from "./Settings/routes"; import { SignMessages } from "./SignMessages/SignMessages"; import MessageRoutes from "./SignMessages/routes"; +import { StakingRewards } from "./Staking/StakingRewards"; import StakingRoutes from "./Staking/routes"; import SwitchAccountRoutes from "./SwitchAccount/routes"; @@ -60,6 +61,10 @@ export const MainRoutes = (): JSX.Element => { element={} errorElement={} /> + } + /> diff --git a/apps/namadillo/src/App/Common/BalanceChart.tsx b/apps/namadillo/src/App/Common/BalanceChart.tsx new file mode 100644 index 000000000..36ac908df --- /dev/null +++ b/apps/namadillo/src/App/Common/BalanceChart.tsx @@ -0,0 +1,96 @@ +import { + Heading, + PieChart, + PieChartData, + SkeletonLoading, +} from "@namada/components"; +import BigNumber from "bignumber.js"; +import { colors } from "theme"; +import { NamCurrency } from "./NamCurrency"; + +type BalanceChartProps = { + view: "total" | "stake"; + bondedAmount: BigNumber; + unbondedAmount: BigNumber; + availableAmount: BigNumber; + withdrawableAmount: BigNumber; + shieldedAmount: BigNumber; + totalAmount: BigNumber; + isLoading: boolean; + isSuccess: boolean; +}; + +export const BalanceChart = ({ + view, + availableAmount, + bondedAmount, + unbondedAmount, + withdrawableAmount, + shieldedAmount, + totalAmount, + isLoading, + isSuccess, +}: BalanceChartProps): JSX.Element => { + const getPiechartData = (): Array => { + if (isLoading) { + return []; + } + + if (totalAmount.eq(0)) { + return [{ value: 1, color: colors.empty }]; + } + + return [ + { value: availableAmount, color: colors.balance }, + { value: shieldedAmount, color: colors.shielded }, + { value: bondedAmount, color: colors.bond }, + { + value: unbondedAmount.plus(withdrawableAmount), + color: colors.unbond, + }, + ]; + }; + + const renderTextSummary = ( + text: string, + balance: BigNumber + ): React.ReactNode => { + return ( +
    + + {text} + + +
    + ); + }; + + return ( +
    + {isLoading && ( + + )} + {isSuccess && ( + + {view === "stake" && + renderTextSummary("Total Staked Balance", bondedAmount)} + {view === "total" && renderTextSummary("Total Balance", totalAmount)} + + )} +
    + ); +}; diff --git a/apps/namadillo/src/App/Common/ModalContainer.tsx b/apps/namadillo/src/App/Common/ModalContainer.tsx index 5cfcf06a3..cabac0e54 100644 --- a/apps/namadillo/src/App/Common/ModalContainer.tsx +++ b/apps/namadillo/src/App/Common/ModalContainer.tsx @@ -27,14 +27,14 @@ export const ModalContainer = ({ clsx( "relative flex flex-col", "w-[100vw] sm:w-[95vw] lg:w-[90vw] 2xl:w-[75vw] h-[100svh] sm:h-[90svh]", - "overflow-auto px-6 py-6 bg-neutral-800 text-white rounded-md" + "overflow-auto px-6 pt-3.5 pb-4 bg-neutral-800 text-white rounded-md" ), containerClassName )} {...otherProps} > diff --git a/apps/namadillo/src/App/Common/ModalTransition.tsx b/apps/namadillo/src/App/Common/ModalTransition.tsx index 20ce0741d..cb7ab595d 100644 --- a/apps/namadillo/src/App/Common/ModalTransition.tsx +++ b/apps/namadillo/src/App/Common/ModalTransition.tsx @@ -11,9 +11,9 @@ export const ModalTransition = ({ }: ModalTransitionProps): JSX.Element => { return ( diff --git a/apps/namadillo/src/App/Staking/IncrementBonding.tsx b/apps/namadillo/src/App/Staking/IncrementBonding.tsx index 52d179859..51a70b0c0 100644 --- a/apps/namadillo/src/App/Staking/IncrementBonding.tsx +++ b/apps/namadillo/src/App/Staking/IncrementBonding.tsx @@ -1,5 +1,5 @@ import { ActionButton, Alert, Modal, Panel } from "@namada/components"; -import { BondMsgValue, BondProps } from "@namada/types"; +import { BondMsgValue } from "@namada/types"; import { AtomErrorBoundary } from "App/Common/AtomErrorBoundary"; import { Info } from "App/Common/Info"; import { ModalContainer } from "App/Common/ModalContainer"; @@ -8,21 +8,15 @@ import { TableRowLoading } from "App/Common/TableRowLoading"; import { TransactionFees } from "App/Common/TransactionFees"; import { accountBalanceAtom, defaultAccountAtom } from "atoms/accounts"; import { chainParametersAtom } from "atoms/chain"; -import { defaultGasConfigFamily } from "atoms/fees"; -import { - createNotificationId, - dispatchToastNotificationAtom, -} from "atoms/notifications"; import { createBondTxAtom } from "atoms/staking"; import { allValidatorsAtom } from "atoms/validators"; import clsx from "clsx"; import { useStakeModule } from "hooks/useStakeModule"; +import { useTransaction } from "hooks/useTransaction"; import { useValidatorFilter } from "hooks/useValidatorFilter"; import { useValidatorSorting } from "hooks/useValidatorSorting"; -import invariant from "invariant"; -import { useAtomValue, useSetAtom } from "jotai"; -import { TransactionPair, broadcastTx } from "lib/query"; -import { useEffect, useRef, useState } from "react"; +import { useAtomValue } from "jotai"; +import { useRef, useState } from "react"; import { GoAlert } from "react-icons/go"; import { useNavigate } from "react-router-dom"; import { ValidatorFilterOptions } from "types"; @@ -37,24 +31,14 @@ const IncrementBonding = (): JSX.Element => { const [validatorFilter, setValidatorFilter] = useState("all"); const navigate = useNavigate(); - const { data: chainParameters } = useAtomValue(chainParametersAtom); const accountBalance = useAtomValue(accountBalanceAtom); const seed = useRef(Math.random()); + const { data: chainParameters } = useAtomValue(chainParametersAtom); const { data: account } = useAtomValue(defaultAccountAtom); const validators = useAtomValue(allValidatorsAtom); - const dispatchNotification = useSetAtom(dispatchToastNotificationAtom); const resultsPerPage = 100; - const { - mutate: createBondTransaction, - isPending: isPerformingBond, - isSuccess, - isError, - data: bondTransactionData, - error: bondTransactionError, - } = useAtomValue(createBondTxAtom); - const { myValidators, totalUpdatedAmount, @@ -63,14 +47,47 @@ const IncrementBonding = (): JSX.Element => { stakedAmountByAddress, updatedAmountByAddress, onChangeValidatorAmount, - parseUpdatedAmounts, } = useStakeModule({ account }); - const gasConfig = useAtomValue( - defaultGasConfigFamily( - Array(Object.keys(updatedAmountByAddress).length).fill("Bond") - ) - ); + const parseUpdatedAmounts = (): BondMsgValue[] => { + if (!account?.address) return []; + return Object.keys(updatedAmountByAddress) + .map((validatorAddress) => ({ + validator: validatorAddress, + source: account.address, + amount: updatedAmountByAddress[validatorAddress], + })) + .filter((entries) => entries.amount.gt(0)); + }; + + const onCloseModal = (): void => navigate(StakingRoutes.overview().url); + + const { + execute: performBonding, + gasConfig, + isEnabled, + isPending: isPerformingBonding, + } = useTransaction({ + createTxAtom: createBondTxAtom, + params: parseUpdatedAmounts(), + eventType: "Bond", + parsePendingTxNotification: () => ({ + title: "Staking transaction in progress", + description: ( + <> + Your staking transaction of{" "} + is being processed + + ), + }), + parseErrorTxNotification: () => ({ + title: "Staking transaction failed", + description: "", + }), + onSuccess: () => { + onCloseModal(); + }, + }); const filteredValidators = useValidatorFilter({ validators: validators.isSuccess ? validators.data : [], @@ -91,77 +108,11 @@ const IncrementBonding = (): JSX.Element => { seed: seed.current, }); - const onCloseModal = (): void => navigate(StakingRoutes.overview().url); - const onSubmit = (e: React.FormEvent): void => { e.preventDefault(); - invariant( - account, - "Extension is not connected or you don't have an account" - ); - const changes = parseUpdatedAmounts(); - - if (!gasConfig.isSuccess) { - throw new Error("Gas config is still pending"); - } - - createBondTransaction({ - changes, - account, - gasConfig: gasConfig.data, - }); - }; - - const dispatchPendingNotification = ( - data?: TransactionPair - ): void => { - dispatchNotification({ - id: createNotificationId(data?.encodedTxData.txs), - title: "Staking transaction in progress", - description: ( - <> - Your staking transaction of{" "} - is being processed - - ), - type: "pending", - }); + performBonding(); }; - const dispatchBondingTransaction = (tx: TransactionPair): void => { - tx.signedTxs.forEach((signedTx) => { - broadcastTx( - tx.encodedTxData, - signedTx, - tx.encodedTxData.meta?.props, - "Bond" - ); - }); - }; - - useEffect(() => { - if (isSuccess) { - bondTransactionData && dispatchBondingTransaction(bondTransactionData); - dispatchPendingNotification(bondTransactionData); - onCloseModal(); - } - }, [isSuccess]); - - useEffect(() => { - if (isError) { - dispatchNotification({ - id: createNotificationId(), - title: "Staking transaction failed", - description: "", - details: - bondTransactionError instanceof Error ? - bondTransactionError.message - : undefined, - type: "error", - }); - } - }, [isError]); - const errorMessage = ((): string => { if (accountBalance.isPending) return "Loading..."; if (accountBalance.data?.lt(totalUpdatedAmount)) @@ -271,15 +222,15 @@ const IncrementBonding = (): JSX.Element => { className="mt-2 col-start-2" backgroundColor="cyan" disabled={ - !!errorMessage || isPerformingBond || totalUpdatedAmount.eq(0) + !!errorMessage || totalUpdatedAmount.eq(0) || !isEnabled } > - {isPerformingBond ? "Processing..." : errorMessage || "Stake"} + {isPerformingBonding ? "Processing..." : errorMessage || "Stake"} - {gasConfig.isSuccess && ( + {gasConfig && ( )}
    diff --git a/apps/namadillo/src/App/Staking/ReDelegate.tsx b/apps/namadillo/src/App/Staking/ReDelegate.tsx index 7b5df1282..bed84f6cd 100644 --- a/apps/namadillo/src/App/Staking/ReDelegate.tsx +++ b/apps/namadillo/src/App/Staking/ReDelegate.tsx @@ -3,24 +3,18 @@ import { RedelegateMsgValue } from "@namada/types"; import { Info } from "App/Common/Info"; import { ModalContainer } from "App/Common/ModalContainer"; import { defaultAccountAtom } from "atoms/accounts"; -import { defaultGasConfigFamily } from "atoms/fees"; -import { - createNotificationId, - dispatchToastNotificationAtom, -} from "atoms/notifications"; import { createReDelegateTxAtom } from "atoms/staking"; import { allValidatorsAtom } from "atoms/validators"; import BigNumber from "bignumber.js"; import clsx from "clsx"; import { useStakeModule } from "hooks/useStakeModule"; -import invariant from "invariant"; -import { useAtomValue, useSetAtom } from "jotai"; -import { TransactionPair, broadcastTx } from "lib/query"; +import { useTransaction } from "hooks/useTransaction"; +import { useAtomValue } from "jotai"; import { getAmountDistribution } from "lib/staking"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { twMerge } from "tailwind-merge"; -import { TxKind, Validator } from "types"; +import { AddressBalance, Validator } from "types"; import { BondingAmountOverview } from "./BondingAmountOverview"; import { ReDelegateAssignStake } from "./ReDelegateAssignStake"; import { ReDelegateRemoveStake } from "./ReDelegateRemoveStake"; @@ -28,14 +22,11 @@ import StakingRoutes from "./routes"; export const ReDelegate = (): JSX.Element => { const [step, setStep] = useState<"remove" | "assign">("remove"); - const [amountsToAssignByAddress, setAmountToAssignByAddress] = useState< - Record - >({}); - - const navigate = useNavigate(); - const dispatchNotification = useSetAtom(dispatchToastNotificationAtom); + const [amountsToAssignByAddress, setAmountToAssignByAddress] = + useState({}); const { data: account } = useAtomValue(defaultAccountAtom); const validators = useAtomValue(allValidatorsAtom); + const navigate = useNavigate(); const { totalStakedAmount, @@ -46,33 +37,42 @@ export const ReDelegate = (): JSX.Element => { myValidators, } = useStakeModule({ account }); - const changes = getAmountDistribution( - amountsRemovedByAddress, - amountsToAssignByAddress - ); + const parseRedelegateParams = (): RedelegateMsgValue[] => { + if (!account?.address) return []; + return getAmountDistribution( + amountsRemovedByAddress, + amountsToAssignByAddress + ).map( + (distribution) => + ({ + ...distribution, + owner: account?.address, + }) as RedelegateMsgValue + ); + }; - const gasConfig = useAtomValue( - defaultGasConfigFamily(Array(changes.length).fill("Redelegation")) - ); + const onCloseModal = (): void => navigate(StakingRoutes.overview().url); const { - mutate: createRedelegateTx, + execute: performRedelegate, isPending: isCreatingTx, - data: redelegateTxData, - isSuccess, - isError, - error: redelegateTxError, - } = useAtomValue(createReDelegateTxAtom); - - useEffect(() => { - if (isSuccess) { - redelegateTxData && dispatchReDelegateTransaction(redelegateTxData); - dispatchPendingNotification(redelegateTxData); + gasConfig, + } = useTransaction({ + createTxAtom: createReDelegateTxAtom, + eventType: "Redelegate", + params: parseRedelegateParams(), + parsePendingTxNotification: () => ({ + title: "Staking redelegation in progress", + description: <>Your redelegation transaction is being processed, + }), + parseErrorTxNotification: () => ({ + title: "Staking redelegation failed", + description: "", + }), + onSuccess: () => { onCloseModal(); - } - }, [isSuccess]); - - const onCloseModal = (): void => navigate(StakingRoutes.overview().url); + }, + }); const onAssignAmount = ( validator: Validator, @@ -91,63 +91,6 @@ export const ReDelegate = (): JSX.Element => { }); }; - const dispatchPendingNotification = ( - data?: TransactionPair - ): void => { - dispatchNotification({ - id: createNotificationId(data?.encodedTxData.txs), - title: "Staking redelegation in progress", - description: <>Your redelegation transaction is being processed, - type: "pending", - }); - }; - - useEffect(() => { - if (isError) { - dispatchNotification({ - id: createNotificationId(), - title: "Staking redelegation failed", - description: "", - details: - redelegateTxError instanceof Error ? - redelegateTxError.message - : undefined, - type: "error", - }); - } - }, [isError]); - - const dispatchReDelegateTransaction = ( - tx: TransactionPair - ): void => { - tx.signedTxs.forEach((signedTx) => { - broadcastTx( - tx.encodedTxData, - signedTx, - tx.encodedTxData.meta?.props, - "ReDelegate" - ); - }); - }; - - const performRedelegate = (): void => { - invariant(account, `Extension is connected but you don't have an account`); - - if (changes.length === 0) { - throw new Error("No redelegation changes to make"); - } - - if (!gasConfig.isSuccess) { - throw new Error("Gas config loading is still pending"); - } - - createRedelegateTx({ - changes, - gasConfig: gasConfig.data, - account, - }); - }; - const onSubmit = (e: React.FormEvent): void => { e.preventDefault(); // Go to next page or do nothing @@ -254,7 +197,7 @@ export const ReDelegate = (): JSX.Element => { totalAssignedAmounts={totalAssignedAmounts} onChangeAssignedAmount={onAssignAmount} isPerformingRedelegation={isCreatingTx} - redelegateChanges={changes} + redelegateChanges={parseRedelegateParams()} gasConfig={gasConfig} /> )} diff --git a/apps/namadillo/src/App/Staking/ReDelegateAssignStake.tsx b/apps/namadillo/src/App/Staking/ReDelegateAssignStake.tsx index 10550b583..bca28d376 100644 --- a/apps/namadillo/src/App/Staking/ReDelegateAssignStake.tsx +++ b/apps/namadillo/src/App/Staking/ReDelegateAssignStake.tsx @@ -5,7 +5,6 @@ import BigNumber from "bignumber.js"; import clsx from "clsx"; import { useValidatorFilter } from "hooks/useValidatorFilter"; import { useValidatorSorting } from "hooks/useValidatorSorting"; -import { AtomWithQueryResult } from "jotai-tanstack-query"; import { useRef, useState } from "react"; import { twMerge } from "tailwind-merge"; import { @@ -56,7 +55,7 @@ type ReDelegateAssignStakeProps = { ) => void; isPerformingRedelegation: boolean; redelegateChanges: RedelegateChange[]; - gasConfig: AtomWithQueryResult; + gasConfig: GasConfig | undefined; }; export const ReDelegateAssignStake = ({ @@ -162,10 +161,10 @@ export const ReDelegateAssignStake = ({ validation={validation} isPerformingRedelegation={isPerformingRedelegation} /> - {gasConfig.isSuccess && ( + {gasConfig && ( )} diff --git a/apps/namadillo/src/App/Staking/Staking.tsx b/apps/namadillo/src/App/Staking/Staking.tsx index d8468139a..3d7ceb69e 100644 --- a/apps/namadillo/src/App/Staking/Staking.tsx +++ b/apps/namadillo/src/App/Staking/Staking.tsx @@ -7,14 +7,6 @@ import { StakingOverview } from "./StakingOverview"; import Unstake from "./Unstake"; import StakingRoutes from "./routes"; -// This is the parent view for all staking related views. Most of the -// staking specific functions are defined here and passed down as props. -// This contains the main vies in staking: -// * StakingOverview - displaying an overview of the users staking and validators -// * ValidatorDetails - as the name says -// * NewStakingStakingPosition - rendered in modal on top of other content -// this is for creating new staking positions -// * UnstakePositions - rendered in modal on top of other content, for unstaking export const Staking = (): JSX.Element => { const location = useLocation(); useAtomValue(minimumGasPriceAtom); diff --git a/apps/namadillo/src/App/Staking/StakingOverview.tsx b/apps/namadillo/src/App/Staking/StakingOverview.tsx index 5b30f7de2..fcf9e3dbe 100644 --- a/apps/namadillo/src/App/Staking/StakingOverview.tsx +++ b/apps/namadillo/src/App/Staking/StakingOverview.tsx @@ -16,8 +16,8 @@ export const StakingOverview = (): JSX.Element => { const myValidators = useAtomValue(myValidatorsAtom); const hasStaking = myValidators.data?.some((v) => v.stakedAmount?.gt(0)); const hasUnbonded = myValidators.data?.some((v) => v.unbondedAmount?.gt(0)); - const hasWithdraws = myValidators.data?.some( - (v) => v.withdrawableAmount?.gt(0) + const hasWithdraws = myValidators.data?.some((v) => + v.withdrawableAmount?.gt(0) ); return ( diff --git a/apps/namadillo/src/App/Staking/StakingRewards.tsx b/apps/namadillo/src/App/Staking/StakingRewards.tsx new file mode 100644 index 000000000..32544f2de --- /dev/null +++ b/apps/namadillo/src/App/Staking/StakingRewards.tsx @@ -0,0 +1,135 @@ +import { + ActionButton, + Modal, + SkeletonLoading, + Stack, +} from "@namada/components"; +import { ClaimRewardsMsgValue } from "@namada/types"; +import { ModalContainer } from "App/Common/ModalContainer"; +import { NamCurrency } from "App/Common/NamCurrency"; +import { defaultAccountAtom } from "atoms/accounts"; +import { applicationFeaturesAtom } from "atoms/settings"; +import { + claimableRewardsAtom, + claimAndStakeRewardsAtom, + claimRewardsAtom, +} from "atoms/staking"; +import BigNumber from "bignumber.js"; +import { useModalCloseEvent } from "hooks/useModalCloseEvent"; +import { useTransaction } from "hooks/useTransaction"; +import { useAtomValue } from "jotai"; +import { sumBigNumberArray } from "utils"; +import claimRewardsSvg from "./assets/claim-rewards.svg"; + +export const StakingRewards = (): JSX.Element => { + const { data: account } = useAtomValue(defaultAccountAtom); + const { claimRewardsEnabled } = useAtomValue(applicationFeaturesAtom); + const { + isLoading: isLoadingRewards, + isSuccess, + data: rewards, + } = useAtomValue(claimableRewardsAtom); + + const { onCloseModal } = useModalCloseEvent(); + + const parseStakingRewardsParams = (): ClaimRewardsMsgValue[] => { + if (!rewards || Object.values(rewards).length === 0 || !account) return []; + return Object.keys(rewards).map((validatorAddress) => { + return { + validator: validatorAddress, + source: account.address, + }; + }); + }; + + const { + execute: claimRewards, + isEnabled: claimRewardsTxEnabled, + isPending: claimRewardsPending, + } = useTransaction({ + params: parseStakingRewardsParams(), + createTxAtom: claimRewardsAtom, + eventType: "ClaimRewards", + parsePendingTxNotification: () => ({ + title: "Claim rewards transaction is in progress", + description: <>Your rewards claim is being processed, + }), + onSuccess: () => { + onCloseModal(); + }, + }); + + const { + execute: claimRewardsAndStake, + isEnabled: claimAndStakeTxEnabled, + isPending: claimAndStakePending, + } = useTransaction({ + params: parseStakingRewardsParams(), + createTxAtom: claimAndStakeRewardsAtom, + eventType: "ClaimRewards", + parsePendingTxNotification: () => ({ + title: "Claim rewards transaction is in progress", + description: ( + <> + Your rewards claim is being processed and will be staked to the same + validators afterward. + + ), + }), + onSuccess: () => { + onCloseModal(); + }, + }); + + const isLoading = claimRewardsPending || claimAndStakePending; + const availableRewards = + claimRewardsEnabled ? + sumBigNumberArray(Object.values(rewards || {})) + : new BigNumber(0); + + return ( + + + + + +
    + {isLoadingRewards && ( + + )} + {isSuccess && ( + + )} +
    +
    + + claimRewardsAndStake()} + disabled={ + availableRewards.eq(0) || !claimAndStakeTxEnabled || isLoading + } + > + {claimAndStakePending ? "Loading..." : "Claim & Stake"} + + claimRewards()} + disabled={ + availableRewards.eq(0) || !claimRewardsTxEnabled || isLoading + } + type="button" + > + {claimRewardsPending ? "Loading..." : "Claim"} + + +
    +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Staking/StakingRewardsPanel.tsx b/apps/namadillo/src/App/Staking/StakingRewardsPanel.tsx new file mode 100644 index 000000000..853a3a1e5 --- /dev/null +++ b/apps/namadillo/src/App/Staking/StakingRewardsPanel.tsx @@ -0,0 +1,62 @@ +import { ActionButton, AmountSummaryCard } from "@namada/components"; +import { NamCurrency } from "App/Common/NamCurrency"; +import StakingRoutes from "App/Staking/routes"; +import { applicationFeaturesAtom } from "atoms/settings"; +import { claimableRewardsAtom } from "atoms/staking"; +import BigNumber from "bignumber.js"; +import clsx from "clsx"; +import { useAtomValue } from "jotai"; +import { GoStack } from "react-icons/go"; +import { useLocation, useNavigate } from "react-router-dom"; +import { sumBigNumberArray } from "utils"; + +export const StakingRewardsPanel = (): JSX.Element => { + const { claimRewardsEnabled } = useAtomValue(applicationFeaturesAtom); + const { data: rewards } = useAtomValue(claimableRewardsAtom); + const location = useLocation(); + const navigate = useNavigate(); + const availableRewards = + claimRewardsEnabled ? + sumBigNumberArray(Object.values(rewards || {})) + : new BigNumber(0); + const title = + claimRewardsEnabled ? + "Unclaimed Staking Rewards" + : "Staking Rewards will be enabled in phase 2"; + + return ( + + +
    + } + title={title} + mainAmount={ + + } + callToAction={ + + navigate(StakingRoutes.claimRewards().url, { + state: { backgroundLocation: location }, + }) + } + > + Claim + + } + /> + ); +}; diff --git a/apps/namadillo/src/App/Staking/StakingSummary.tsx b/apps/namadillo/src/App/Staking/StakingSummary.tsx index d90536589..d33061aa3 100644 --- a/apps/namadillo/src/App/Staking/StakingSummary.tsx +++ b/apps/namadillo/src/App/Staking/StakingSummary.tsx @@ -1,92 +1,55 @@ import { ActionButton, AmountSummaryCard, - Heading, Image, Panel, - PieChart, - PieChartData, - SkeletonLoading, } from "@namada/components"; import { AtomErrorBoundary } from "App/Common/AtomErrorBoundary"; +import { BalanceChart } from "App/Common/BalanceChart"; import { NamCurrency } from "App/Common/NamCurrency"; -import { accountBalanceAtom } from "atoms/accounts"; -import { getStakingTotalAtom } from "atoms/staking"; -import { useAtomValue } from "jotai"; -import { GoStack } from "react-icons/go"; +import { useBalances } from "hooks/useBalances"; import { useNavigate } from "react-router-dom"; import StakingRoutes from "./routes"; +import { StakingRewardsPanel } from "./StakingRewardsPanel"; export const StakingSummary = (): JSX.Element => { const navigate = useNavigate(); - const totalStakedBalance = useAtomValue(getStakingTotalAtom); - const totalAccountBalance = useAtomValue(accountBalanceAtom); const { - data: balance, - isSuccess: isBalanceLoaded, - isLoading: isFetchingBalance, - } = totalAccountBalance; + balanceQuery, + stakeQuery, + isLoading, + isSuccess, + availableAmount, + bondedAmount, + shieldedAmount, + unbondedAmount, + withdrawableAmount, + totalAmount, + } = useBalances(); - const getPiechartData = (): Array => { - if (!totalStakedBalance.isSuccess || !isBalanceLoaded) { - return []; - } - - const totalStaked = totalStakedBalance.data; - if (totalStaked.totalUnbonded.eq(0) && totalStaked.totalBonded.eq(0)) { - return [{ value: 1, color: "#2F2F2F" }]; - } - - return [ - { value: balance, color: "#ffffff" }, - { value: totalStaked.totalBonded, color: "#00ffff" }, - { - value: totalStaked.totalUnbonded.plus(totalStaked.totalWithdrawable), - color: "#DD1599", - }, - ]; - }; - - // TODO: implement total staking rewards return (
      - {totalStakedBalance.isPending && ( - - )} - {totalStakedBalance.isSuccess && ( - -
      - - Total Staked Balance - - -
      -
      - )} +
      { to Stake } - isLoading={totalStakedBalance.isPending || isFetchingBalance} + isLoading={isLoading} mainAmount={ @@ -119,32 +82,8 @@ export const StakingSummary = (): JSX.Element => { /> - - - -
      - } - title="Staking Rewards will be enabled in phase 2" - mainAmount={ - - } - callToAction={ - - Claim - - } - /> + +
    ); diff --git a/apps/namadillo/src/App/Staking/Unstake.tsx b/apps/namadillo/src/App/Staking/Unstake.tsx index fef33cb0b..44665a668 100644 --- a/apps/namadillo/src/App/Staking/Unstake.tsx +++ b/apps/namadillo/src/App/Staking/Unstake.tsx @@ -1,5 +1,5 @@ import { ActionButton, Alert, Modal, Panel, Stack } from "@namada/components"; -import { UnbondMsgValue, UnbondProps } from "@namada/types"; +import { UnbondMsgValue } from "@namada/types"; import { AtomErrorBoundary } from "App/Common/AtomErrorBoundary"; import { Info } from "App/Common/Info"; import { ModalContainer } from "App/Common/ModalContainer"; @@ -8,20 +8,14 @@ import { TableRowLoading } from "App/Common/TableRowLoading"; import { TransactionFees } from "App/Common/TransactionFees"; import { defaultAccountAtom } from "atoms/accounts"; import { chainParametersAtom } from "atoms/chain"; -import { defaultGasConfigFamily } from "atoms/fees"; -import { - createNotificationId, - dispatchToastNotificationAtom, -} from "atoms/notifications"; import { createUnbondTxAtom } from "atoms/staking"; import { myValidatorsAtom } from "atoms/validators"; import BigNumber from "bignumber.js"; import clsx from "clsx"; import { useStakeModule } from "hooks/useStakeModule"; -import invariant from "invariant"; -import { useAtomValue, useSetAtom } from "jotai"; -import { TransactionPair, broadcastTx } from "lib/query"; -import { FormEvent, useEffect } from "react"; +import { useTransaction } from "hooks/useTransaction"; +import { useAtomValue } from "jotai"; +import { FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { MyValidator } from "types"; import { BondingAmountOverview } from "./BondingAmountOverview"; @@ -32,20 +26,9 @@ const Unstake = (): JSX.Element => { const navigate = useNavigate(); const { data: account } = useAtomValue(defaultAccountAtom); const validators = useAtomValue(myValidatorsAtom); - const dispatchNotification = useSetAtom(dispatchToastNotificationAtom); const { data: chainParameters } = useAtomValue(chainParametersAtom); const { - mutate: createUnbondTx, - isPending: isPerformingUnbond, - data: unbondTransactionData, - isSuccess, - isError, - error: unstakeTxError, - } = useAtomValue(createUnbondTxAtom); - - const { - parseUpdatedAmounts, totalStakedAmount, stakedAmountByAddress, updatedAmountByAddress, @@ -53,14 +36,44 @@ const Unstake = (): JSX.Element => { onChangeValidatorAmount, } = useStakeModule({ account }); - const gasConfig = useAtomValue( - defaultGasConfigFamily( - Array(Object.keys(updatedAmountByAddress).length).fill("Unbond") - ) - ); + const parseUnstakeParams = (): UnbondMsgValue[] => { + if (!account?.address) return []; + return Object.keys(updatedAmountByAddress).map((validatorAddress) => ({ + validator: validatorAddress, + source: account.address, + amount: updatedAmountByAddress[validatorAddress], + })); + }; const onCloseModal = (): void => navigate(StakingRoutes.overview().url); + const { + execute: performUnbond, + gasConfig, + isPending: isPerformingUnbond, + isEnabled, + } = useTransaction({ + createTxAtom: createUnbondTxAtom, + params: parseUnstakeParams(), + eventType: "Unbond", + parsePendingTxNotification: () => ({ + title: "Unstake transaction in progress", + description: ( + <> + Your unstaking transaction of{" "} + is being processed + + ), + }), + parseErrorTxNotification: () => ({ + title: "Unstake transaction failed", + description: "", + }), + onSuccess: () => { + onCloseModal(); + }, + }); + const onUnbondAll = (): void => { if (!validators.isSuccess) return; validators.data.forEach((myValidator: MyValidator) => @@ -73,77 +86,11 @@ const Unstake = (): JSX.Element => { const onSubmit = (e: FormEvent): void => { e.preventDefault(); - invariant( - account, - "Extension is not connected or you don't have an account" - ); - const changes = parseUpdatedAmounts(); - - if (!gasConfig.isSuccess) { - throw new Error("Gas config loading is still pending"); - } - - createUnbondTx({ - changes, - account, - gasConfig: gasConfig.data, - }); - }; - - const dispatchPendingNotification = ( - data?: TransactionPair - ): void => { - dispatchNotification({ - id: createNotificationId(data?.encodedTxData.txs), - title: "Unstake transaction in progress", - description: ( - <> - Your unstaking transaction of{" "} - is being processed - - ), - type: "pending", - }); - }; - - useEffect(() => { - if (isError) { - dispatchNotification({ - id: createNotificationId(), - title: "Unstake transaction failed", - description: "", - details: - unstakeTxError instanceof Error ? unstakeTxError.message : undefined, - type: "error", - }); - } - }, [isError]); - - const dispatchUnbondingTransaction = ( - tx: TransactionPair - ): void => { - tx.signedTxs.forEach((signedTx) => { - broadcastTx( - tx.encodedTxData, - signedTx, - tx.encodedTxData.meta?.props, - "Unbond" - ); - }); + performUnbond(); }; - useEffect(() => { - if (isSuccess) { - unbondTransactionData && - dispatchUnbondingTransaction(unbondTransactionData); - dispatchPendingNotification(unbondTransactionData); - onCloseModal(); - } - }, [isSuccess]); - const validationMessage = ((): string => { if (totalStakedAmount.lt(totalUpdatedAmount)) return "Invalid amount"; - for (const address in updatedAmountByAddress) { if (stakedAmountByAddress[address].lt(updatedAmountByAddress[address])) { return "Invalid amount"; @@ -258,19 +205,17 @@ const Unstake = (): JSX.Element => { backgroundHoverColor="pink" className="mt-2 col-start-2" disabled={ - !!validationMessage || - isPerformingUnbond || - totalUpdatedAmount.eq(0) + !!validationMessage || !isEnabled || totalUpdatedAmount.eq(0) } > {isPerformingUnbond ? "Processing..." : validationMessage || "Unstake"} - {gasConfig.isSuccess && ( + {gasConfig && ( )} diff --git a/apps/namadillo/src/App/Staking/WithdrawalButton.tsx b/apps/namadillo/src/App/Staking/WithdrawalButton.tsx index 0c4189672..81facd76a 100644 --- a/apps/namadillo/src/App/Staking/WithdrawalButton.tsx +++ b/apps/namadillo/src/App/Staking/WithdrawalButton.tsx @@ -1,19 +1,12 @@ import { ActionButton } from "@namada/components"; -import { BondMsgValue, WithdrawMsgValue } from "@namada/types"; +import { WithdrawMsgValue } from "@namada/types"; import { NamCurrency } from "App/Common/NamCurrency"; import { defaultAccountAtom } from "atoms/accounts"; -import { gasLimitsAtom } from "atoms/fees"; -import { - createNotificationId, - dispatchToastNotificationAtom, -} from "atoms/notifications"; import { createWithdrawTxAtomFamily } from "atoms/staking"; import BigNumber from "bignumber.js"; -import { useGasEstimate } from "hooks/useGasEstimate"; -import invariant from "invariant"; -import { useAtomValue, useSetAtom } from "jotai"; -import { TransactionPair, broadcastTx } from "lib/query"; -import { useCallback, useEffect } from "react"; +import { useTransaction } from "hooks/useTransaction"; +import { useAtomValue } from "jotai"; +import { useEffect } from "react"; import { MyValidator, UnbondEntry } from "types"; type WithdrawalButtonProps = { @@ -25,114 +18,59 @@ export const WithdrawalButton = ({ myValidator, unbondingEntry, }: WithdrawalButtonProps): JSX.Element => { - const change = { - validatorId: myValidator.validator.address, - amount: unbondingEntry.amount, - }; - - const { gasPrice } = useGasEstimate(); - const gasLimits = useAtomValue(gasLimitsAtom); const { data: account } = useAtomValue(defaultAccountAtom); - const withdrawFamilyId = `${change.validatorId}- ${change.amount}`; - - const { - mutate: createWithdrawTx, - data: withdrawalTx, - isPending, - isSuccess, - isError, - error: withdrawalTransactionError, - } = useAtomValue(createWithdrawTxAtomFamily(withdrawFamilyId)); - - useEffect(() => { - return () => { - // On detach we have to remove the param to avoid memory leaks - createWithdrawTxAtomFamily.remove(withdrawFamilyId); - }; - }, []); - const onWithdraw = useCallback(async () => { - invariant( - account, - "Extension is not connected or you don't have an account" - ); - invariant(gasPrice, "Gas price loading is still pending"); - invariant(gasLimits.isSuccess, "Gas limit loading is still pending"); - invariant( - unbondingEntry.amount, - "Validator doesn't have amounts available for withdrawal" - ); - createWithdrawTx({ - changes: [change], - gasConfig: { - gasPrice: gasPrice, - gasLimit: gasLimits.data.Withdraw.native, - }, - account, - }); - }, [unbondingEntry.amount, change, gasPrice, gasLimits.isSuccess]); + const getFamilyId = (): string => + `${myValidator.validator.address}- ${unbondingEntry.amount}`; - const dispatchNotification = useSetAtom(dispatchToastNotificationAtom); - - const dispatchWithdrawalTransactions = async ( - tx: TransactionPair - ): Promise => { - tx.signedTxs.forEach((signedTx) => { - broadcastTx( - tx.encodedTxData, - signedTx, - tx.encodedTxData.meta?.props, - "Withdraw" - ); - }); + const parseWithdrawParams = (): WithdrawMsgValue[] => { + if (!account?.address) return []; + return [ + { validator: myValidator.validator.address, source: account?.address }, + ]; }; - const dispatchPendingNotification = ( - transaction: TransactionPair, - props: BondMsgValue - ): void => { - dispatchNotification({ - id: createNotificationId(transaction.encodedTxData.txs), + const { + execute: performWithdraw, + isPending, + isSuccess, + isEnabled, + } = useTransaction({ + createTxAtom: createWithdrawTxAtomFamily(getFamilyId()), + params: parseWithdrawParams(), + eventType: "Withdraw", + parsePendingTxNotification: () => ({ title: "Withdrawal transaction in progress", description: ( <> The withdrawal of{" "} - is being - processed + is + being processed ), - type: "pending", - }); - }; + }), + parseErrorTxNotification: () => ({ + title: "Withdrawal transaction failed", + description: "", + }), + }); useEffect(() => { - if (withdrawalTx) { - const [tx, props] = withdrawalTx; - dispatchPendingNotification(tx, props); - dispatchWithdrawalTransactions(tx); - } - }, [isSuccess]); + return () => { + // On detach we have to remove the param to avoid memory leaks + createWithdrawTxAtomFamily.remove(getFamilyId()); + }; + }, []); - useEffect(() => { - if (isError) { - dispatchNotification({ - id: createNotificationId(), - title: "Withdrawal transaction failed", - description: "", - details: - withdrawalTransactionError instanceof Error ? - withdrawalTransactionError.message - : undefined, - type: "error", - }); - } - }, [isError]); + const onWithdraw = (): void => { + performWithdraw(); + }; return ( onWithdraw()} > {isSuccess && "Claimed"} diff --git a/apps/namadillo/src/App/Staking/__tests__/StakingRewards.test.tsx b/apps/namadillo/src/App/Staking/__tests__/StakingRewards.test.tsx new file mode 100644 index 000000000..35bf6fe54 --- /dev/null +++ b/apps/namadillo/src/App/Staking/__tests__/StakingRewards.test.tsx @@ -0,0 +1,180 @@ +import BigNumber from "bignumber.js"; +import { mockJotai } from "test-utils"; +mockJotai(); + +import { fireEvent, render, screen } from "@testing-library/react"; +import { StakingRewards } from "App/Staking/StakingRewards"; +import { defaultAccountAtom } from "atoms/accounts"; +import { applicationFeaturesAtom } from "atoms/settings"; +import { claimableRewardsAtom } from "atoms/staking"; +import { useTransaction } from "hooks/useTransaction"; +import { useAtomValue } from "jotai"; +import { AddressBalance } from "types"; + +jest.mock("hooks/useTransaction", () => ({ + useTransaction: jest.fn(), +})); + +jest.mock("hooks/useModalCloseEvent", () => ({ + useModalCloseEvent: () => ({ onCloseModal: jest.fn() }), +})); + +jest.mock("atoms/staking", () => ({ + claimableRewardsAtom: jest.fn(), + claimAndStakeRewardsAtom: jest.fn(), + claimRewardsAtom: jest.fn(), +})); + +const mockAtomValue = ( + data: AddressBalance = {}, + isLoading: boolean = true, + isSuccess: boolean = false, + rewardsEnabled: boolean = true +): void => { + (useAtomValue as jest.Mock).mockImplementation((atom) => { + if (atom === defaultAccountAtom) { + return { data: { address: "tnam1_test_account" } }; + } + + if (atom === claimableRewardsAtom) { + return { data, isLoading, isSuccess }; + } + + if (atom === applicationFeaturesAtom) { + return { claimRewardsEnabled: rewardsEnabled }; + } + + return null; + }); +}; + +const mockTransaction = ( + execute: jest.Mock = jest.fn(), + isEnabled: boolean = false, + isPending: boolean = false +): void => { + (useTransaction as jest.Mock).mockImplementation(() => { + return { + execute, + isEnabled, + isPending, + }; + }); +}; + +describe("Component: StakingRewards", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const setup = (): void => { + render(); + }; + + const getButtons = (): HTMLElement[] => { + return screen.getAllByRole("button"); + }; + + it("should render modal correctly", () => { + mockAtomValue(); + mockTransaction(); + setup(); + expect(screen.getByText("Claimable Staking Rewards")).toBeInTheDocument(); + expect(getButtons()).toHaveLength(2); + }); + + it("should render loading skeleton when rewards are loading", () => { + mockAtomValue({}, true); + mockTransaction(); + render(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + + const buttons = getButtons(); + buttons.forEach((button) => expect(button).toBeDisabled()); + }); + + it("should display zero rewards and disabled buttons when no rewards available", () => { + mockAtomValue({}, false, true); + mockTransaction(jest.fn(), true, false); + render(); + expect(screen.getByText("0")).toBeInTheDocument(); + + const buttons = getButtons(); + buttons.forEach((button) => expect(button).toBeDisabled()); + }); + + it("should display available rewards when loaded", () => { + mockAtomValue( + { + validator1: new BigNumber(100), + validator2: new BigNumber(200), + }, + false, + true + ); + mockTransaction(); + render(); + expect(screen.getByText("300")).toBeInTheDocument(); + }); + + it("should enable buttons if claim rewards are available", () => { + mockAtomValue( + { + validator1: new BigNumber(100), + }, + false, + true + ); + mockTransaction(jest.fn(), true, false); + render(); + const buttons = getButtons(); + buttons.forEach((button) => expect(button).toBeEnabled()); + }); + + it("should disable buttons if claim rewards are not enabled", () => { + mockAtomValue( + { + validator1: new BigNumber(100), + }, + false, + true, + false + ); + mockTransaction(jest.fn(), false, false); + render(); + const buttons = getButtons(); + buttons.forEach((button) => expect(button).not.toBeEnabled()); + }); + + it("should disable buttons while transaction is pending", () => { + mockAtomValue( + { + validator1: new BigNumber(100), + }, + false, + true + ); + mockTransaction(jest.fn(), true, true); + render(); + const buttons = getButtons(); + buttons.forEach((button) => expect(button).not.toBeEnabled()); + }); + + it("should call 'claimRewardsAndStake' when 'Claim & Stake' is clicked", async () => { + const executeMock = jest.fn(); + mockTransaction(executeMock, true, false); + mockAtomValue( + { + validator1: new BigNumber(100), + }, + false, + true + ); + render(); + const buttons = getButtons(); + fireEvent.click(buttons[0]); + expect(executeMock).toHaveBeenCalledTimes(1); + fireEvent.click(buttons[1]); + expect(executeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/namadillo/src/App/Staking/__tests__/StakingRewardsPanel.test.tsx b/apps/namadillo/src/App/Staking/__tests__/StakingRewardsPanel.test.tsx new file mode 100644 index 000000000..7ea5af342 --- /dev/null +++ b/apps/namadillo/src/App/Staking/__tests__/StakingRewardsPanel.test.tsx @@ -0,0 +1,92 @@ +import { mockJotai, mockReactRouterDom } from "test-utils"; +mockReactRouterDom("/"); +mockJotai(); + +import { fireEvent, render, screen } from "@testing-library/react"; +import StakingRoutes from "App/Staking/routes"; +import { StakingRewardsPanel } from "App/Staking/StakingRewardsPanel"; +import { applicationFeaturesAtom } from "atoms/settings"; +import { claimableRewardsAtom } from "atoms/staking"; +import BigNumber from "bignumber.js"; +import { useAtomValue } from "jotai"; + +jest.mock("atoms/staking", () => ({ + claimableRewardsAtom: jest.fn(), +})); + +// eslint-disable-next-line +const mockAtomValues = (claimRewardsEnabled: boolean, rewards: any) => { + (useAtomValue as jest.Mock).mockImplementation((atom) => { + if (atom === applicationFeaturesAtom) { + return { claimRewardsEnabled }; + } + if (atom === claimableRewardsAtom) { + return { data: rewards }; + } + return null; + }); +}; + +describe("Component: StakingRewardsPanel", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = (): void => { + render(); + }; + + const getClaimButton = (): HTMLElement => + screen.getByRole("button", { name: /Claim/i }); + + it("renders the component with rewards disabled", () => { + mockAtomValues(false, {}); + setup(); + expect(screen.getByText(/will be enabled/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Claim/i })).toBeDisabled(); + }); + + it("renders the component with rewards disabled, even if user has rewards to be claimed", () => { + mockAtomValues(false, { + validator1: BigNumber(10), + validato2: BigNumber(20), + }); + setup(); + expect(screen.getByText(/will be enabled/i)).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Claim/i })).toBeDisabled(); + }); + + it("renders with rewards enabled, but without anything to be claimed", () => { + mockAtomValues(true, {}); + setup(); + expect(screen.getByText(/unclaimed/i)).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + const claimButton = getClaimButton(); + expect(claimButton).toBeDisabled(); + }); + + it("renders with rewards to be claimed", () => { + mockAtomValues(true, { + validator1: BigNumber(10), + validator2: BigNumber(20), + }); + + const mockNavigate = jest.fn(); + jest + .spyOn(require("react-router-dom"), "useNavigate") + .mockReturnValue(mockNavigate); + + setup(); + expect(screen.getByText(/unclaimed/i)).toBeInTheDocument(); + const claimButton = getClaimButton(); + expect(claimButton).not.toBeDisabled(); + fireEvent.click(claimButton); + expect(mockNavigate).toHaveBeenCalledWith( + StakingRoutes.claimRewards().url, + { + state: { backgroundLocation: { pathname: "/" } }, + } + ); + }); +}); diff --git a/apps/namadillo/src/App/Staking/assets/claim-rewards.svg b/apps/namadillo/src/App/Staking/assets/claim-rewards.svg new file mode 100644 index 000000000..0d406b883 --- /dev/null +++ b/apps/namadillo/src/App/Staking/assets/claim-rewards.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/namadillo/src/App/Staking/routes.ts b/apps/namadillo/src/App/Staking/routes.ts index 6a0f2143e..ee9166a91 100644 --- a/apps/namadillo/src/App/Staking/routes.ts +++ b/apps/namadillo/src/App/Staking/routes.ts @@ -14,6 +14,8 @@ export const incrementBonding = (): RouteOutput => export const redelegateBonding = (): RouteOutput => routeOutput("/bonding/redelegate"); +export const claimRewards = (): RouteOutput => routeOutput("/claim-rewards"); + export const validatorDetails = (id: string | number): RouteOutput => routeOutput(`/validator-details/${id}`); @@ -32,5 +34,6 @@ export default { validatorDetailsOwner, incrementBonding, redelegateBonding, + claimRewards, unstake, }; diff --git a/apps/namadillo/src/atoms/fees/atoms.ts b/apps/namadillo/src/atoms/fees/atoms.ts index 659ed3340..9695471df 100644 --- a/apps/namadillo/src/atoms/fees/atoms.ts +++ b/apps/namadillo/src/atoms/fees/atoms.ts @@ -21,7 +21,7 @@ export const txKindFromIndexer = ( case GasLimitTableIndexer.Unbond: return "Unbond"; case GasLimitTableIndexer.Redelegation: - return "Redelegation"; + return "Redelegate"; case GasLimitTableIndexer.Withdraw: return "Withdraw"; case GasLimitTableIndexer.ClaimRewards: diff --git a/apps/namadillo/src/atoms/staking/atoms.ts b/apps/namadillo/src/atoms/staking/atoms.ts index b88e6790d..fef4c722e 100644 --- a/apps/namadillo/src/atoms/staking/atoms.ts +++ b/apps/namadillo/src/atoms/staking/atoms.ts @@ -1,21 +1,29 @@ -import { BondProps, WithdrawProps } from "@namada/types"; -import { chainAtom } from "atoms/chain"; +import { Reward } from "@anomaorg/namada-indexer-client"; +import { + BondMsgValue, + ClaimRewardsMsgValue, + RedelegateMsgValue, + UnbondMsgValue, + WithdrawMsgValue, +} from "@namada/types"; +import { defaultAccountAtom } from "atoms/accounts"; +import { indexerApiAtom } from "atoms/api"; +import { chainAtom, chainParametersAtom } from "atoms/chain"; import { queryDependentFn } from "atoms/utils"; import { myValidatorsAtom } from "atoms/validators"; +import BigNumber from "bignumber.js"; import { atomWithMutation, atomWithQuery } from "jotai-tanstack-query"; import { atomFamily } from "jotai/utils"; -import { TransactionPair } from "lib/query"; -import { - ChangeInStakingProps, - RedelegateChangesProps, - StakingTotals, -} from "types"; +import { AddressBalance, BuildTxAtomParams, StakingTotals } from "types"; import { toStakingTotal } from "./functions"; import { createBondTx, + createClaimAndStakeTx, + createClaimTx, createReDelegateTx, createUnbondTx, createWithdrawTx, + fetchClaimableRewards, } from "./services"; export const getStakingTotalAtom = atomWithQuery((get) => { @@ -35,8 +43,12 @@ export const createBondTxAtom = atomWithMutation((get) => { return { mutationKey: ["create-bonding-tx"], enabled: chain.isSuccess, - mutationFn: async ({ changes, gasConfig, account }: ChangeInStakingProps) => - createBondTx(chain.data!, account, changes, gasConfig), + mutationFn: async ({ + params, + gasConfig, + account, + }: BuildTxAtomParams) => + createBondTx(chain.data!, account, params, gasConfig), }; }); @@ -45,37 +57,26 @@ export const createUnbondTxAtom = atomWithMutation((get) => { return { mutationKey: ["create-unbonding-tx"], enabled: chain.isSuccess, - mutationFn: async ({ changes, gasConfig, account }: ChangeInStakingProps) => - createUnbondTx(chain.data!, account, changes, gasConfig), - }; -}); - -export const createReDelegateTxAtom = atomWithMutation((get) => { - const chain = get(chainAtom); - return { - mutationKey: ["create-redelegate-tx"], - enabled: chain.isSuccess, mutationFn: async ({ - changes, + params, gasConfig, account, - }: RedelegateChangesProps) => - createReDelegateTx(chain.data!, account, changes, gasConfig), + }: BuildTxAtomParams) => + createUnbondTx(chain.data!, account, params, gasConfig), }; }); -export const createWithdrawTxAtom = atomWithMutation((get) => { +export const createReDelegateTxAtom = atomWithMutation((get) => { const chain = get(chainAtom); return { - mutationKey: ["create-withdraw-tx"], + mutationKey: ["create-redelegate-tx"], enabled: chain.isSuccess, mutationFn: async ({ - changes, + params, gasConfig, account, - }: ChangeInStakingProps): Promise< - [TransactionPair, BondProps] | undefined - > => createWithdrawTx(chain.data!, account, changes, gasConfig), + }: BuildTxAtomParams) => + createReDelegateTx(chain.data!, account, params, gasConfig), }; }); @@ -86,12 +87,71 @@ export const createWithdrawTxAtomFamily = atomFamily((id: string) => { mutationKey: ["create-withdraw-tx", id], enabled: chain.isSuccess, mutationFn: async ({ - changes, + params, gasConfig, account, - }: ChangeInStakingProps): Promise< - [TransactionPair, BondProps] | undefined - > => createWithdrawTx(chain.data!, account, changes, gasConfig), + }: BuildTxAtomParams) => + createWithdrawTx(chain.data!, account, params, gasConfig), }; }); }); + +export const claimRewardsAtom = atomWithMutation((get) => { + const chain = get(chainAtom); + return { + mutationKey: ["create-claim-tx"], + enabled: chain.isSuccess, + mutationFn: async ({ + params, + gasConfig, + account, + }: BuildTxAtomParams) => { + return createClaimTx(chain.data!, account, params, gasConfig); + }, + }; +}); + +export const claimableRewardsAtom = atomWithQuery((get) => { + const account = get(defaultAccountAtom); + const chainParameters = get(chainParametersAtom); + const api = get(indexerApiAtom); + return { + queryKey: ["claim-rewards", account.data?.address], + refetchInterval: 60 * 1000, + ...queryDependentFn(async () => { + const rewards = await fetchClaimableRewards(api, account.data!.address); + return rewards.reduce( + (prev: AddressBalance, current: Reward): AddressBalance => { + if (!current.validator) return prev; + return { + ...prev, + [current.validator?.address]: new BigNumber(current.amount || 0), + }; + }, + {} + ); + }, [account, chainParameters]), + }; +}); + +export const claimAndStakeRewardsAtom = atomWithMutation((get) => { + const chain = get(chainAtom); + const claimableRewards = get(claimableRewardsAtom); + return { + mutationKey: ["create-claim-and-stake-tx"], + enabled: chain.isSuccess && claimableRewards.isSuccess, + mutationFn: async ({ + params, + gasConfig, + account, + }: BuildTxAtomParams) => { + return createClaimAndStakeTx( + chain.data!, + account, + params, + claimableRewards.data!, + gasConfig + ); + }, + }; +}); diff --git a/apps/namadillo/src/atoms/staking/services.ts b/apps/namadillo/src/atoms/staking/services.ts index 50de429d1..7016e3f69 100644 --- a/apps/namadillo/src/atoms/staking/services.ts +++ b/apps/namadillo/src/atoms/staking/services.ts @@ -1,35 +1,36 @@ +import { DefaultApi, Reward } from "@anomaorg/namada-indexer-client"; import { Account, + BondMsgValue, BondProps, + ClaimRewardsMsgValue, + ClaimRewardsProps, RedelegateMsgValue, + TxMsgValue, UnbondMsgValue, + WithdrawMsgValue, WithdrawProps, + WrapperTxProps, } from "@namada/types"; import { getSdkInstance } from "hooks"; import { TransactionPair, buildTxPair } from "lib/query"; -import { - ChainSettings, - ChangeInStakingPosition, - GasConfig, - RedelegateChange, -} from "types"; -import { - getRedelegateChangeParams, - getStakingChangesParams, -} from "./functions"; +import { Address, AddressBalance, ChainSettings, GasConfig } from "types"; + +export const fetchClaimableRewards = async ( + api: DefaultApi, + address: Address +): Promise => { + const response = await api.apiV1PosRewardAddressGet(address); + return response.data; +}; export const createBondTx = async ( chain: ChainSettings, account: Account, - changes: ChangeInStakingPosition[], + bondProps: BondMsgValue[], gasConfig: GasConfig ): Promise | undefined> => { const { tx } = await getSdkInstance(); - const bondProps = getStakingChangesParams( - account, - chain.nativeTokenAddress, - changes - ); const transactionPairs = await buildTxPair( account, gasConfig, @@ -44,15 +45,10 @@ export const createBondTx = async ( export const createUnbondTx = async ( chain: ChainSettings, account: Account, - changes: ChangeInStakingPosition[], + unbondProps: UnbondMsgValue[], gasConfig: GasConfig ): Promise> => { const { tx } = await getSdkInstance(); - const unbondProps = getStakingChangesParams( - account, - chain.nativeTokenAddress, - changes - ); const transactionPairs = await buildTxPair( account, gasConfig, @@ -67,11 +63,10 @@ export const createUnbondTx = async ( export const createReDelegateTx = async ( chain: ChainSettings, account: Account, - changes: RedelegateChange[], + redelegateProps: RedelegateMsgValue[], gasConfig: GasConfig ): Promise> => { const { tx } = await getSdkInstance(); - const redelegateProps = getRedelegateChangeParams(account, changes); const transactionPairs = await buildTxPair( account, gasConfig, @@ -86,15 +81,10 @@ export const createReDelegateTx = async ( export const createWithdrawTx = async ( chain: ChainSettings, account: Account, - changes: ChangeInStakingPosition[], + withdrawProps: WithdrawMsgValue[], gasConfig: GasConfig -): Promise<[TransactionPair, BondProps] | undefined> => { +): Promise> => { const { tx } = await getSdkInstance(); - const withdrawProps = getStakingChangesParams( - account, - chain.nativeTokenAddress, - changes - ); const transactionPair = await buildTxPair( account, gasConfig, @@ -103,6 +93,69 @@ export const createWithdrawTx = async ( tx.buildWithdraw, withdrawProps[0].source ); + return transactionPair; +}; - return [transactionPair, withdrawProps[0]]; +export const createClaimTx = async ( + chain: ChainSettings, + account: Account, + params: ClaimRewardsMsgValue[], + gasConfig: GasConfig +): Promise> => { + const { tx } = await getSdkInstance(); + return await buildTxPair( + account, + gasConfig, + chain, + params, + tx.buildClaimRewards, + account.address + ); +}; + +export const createClaimAndStakeTx = async ( + chain: ChainSettings, + account: Account, + params: ClaimRewardsMsgValue[], + claimableRewardsByValidator: AddressBalance, + gasConfig: GasConfig +): Promise> => { + const { tx } = await getSdkInstance(); + + // BuildTx wrapper to handle different commitment types + const buildClaimRewardsAndStake = async ( + wrapperTxProps: WrapperTxProps, + props: ClaimRewardsProps | BondProps + ): Promise => { + if ("amount" in props) { + return tx.buildBond(wrapperTxProps, props as BondProps); + } else { + return tx.buildClaimRewards(wrapperTxProps, props as ClaimRewardsProps); + } + }; + + // Adding bonding commitments after the claiming ones. Order is strictly + // important in this case + const claimAndStakingParams: (ClaimRewardsMsgValue | BondMsgValue)[] = + Array.from(params); + + params.forEach((claimParam) => { + const { validator, source } = claimParam; + if (claimableRewardsByValidator.hasOwnProperty(validator)) { + claimAndStakingParams.push({ + amount: claimableRewardsByValidator[validator], + source, + validator, + } as BondMsgValue); + } + }); + + return await buildTxPair( + account, + gasConfig, + chain, + claimAndStakingParams, + buildClaimRewardsAndStake, + account.address + ); }; diff --git a/apps/namadillo/src/hooks/__mocks__/mockUseBalance.ts b/apps/namadillo/src/hooks/__mocks__/mockUseBalance.ts new file mode 100644 index 000000000..d55602e13 --- /dev/null +++ b/apps/namadillo/src/hooks/__mocks__/mockUseBalance.ts @@ -0,0 +1,21 @@ +import BigNumber from "bignumber.js"; +import { useBalancesOutput } from "hooks/useBalances"; + +export const useBalanceOutputMock = { + balanceQuery: { isError: false }, + stakeQuery: { isError: false }, + isLoading: false, + isSuccess: true, + availableAmount: new BigNumber(100), + bondedAmount: new BigNumber(50), + unbondedAmount: new BigNumber(30), + withdrawableAmount: new BigNumber(20), + totalAmount: new BigNumber(200), +}; + +export const mockUseBalances = ( + props: Partial = {} +): void => { + const useBalances = jest.spyOn(require("hooks/useBalances"), "useBalances"); + useBalances.mockReturnValue({ ...useBalanceOutputMock, ...props }); +}; diff --git a/apps/namadillo/src/hooks/useBalances.ts b/apps/namadillo/src/hooks/useBalances.ts new file mode 100644 index 000000000..9a68ed87b --- /dev/null +++ b/apps/namadillo/src/hooks/useBalances.ts @@ -0,0 +1,63 @@ +import { accountBalanceAtom } from "atoms/accounts"; +import { getStakingTotalAtom } from "atoms/staking"; +import BigNumber from "bignumber.js"; +import { useAtomValue } from "jotai"; +import { AtomWithQueryResult } from "jotai-tanstack-query"; + +export type useBalancesOutput = { + isLoading: boolean; + isSuccess: boolean; + stakeQuery: AtomWithQueryResult; + balanceQuery: AtomWithQueryResult; + bondedAmount: BigNumber; + availableAmount: BigNumber; + unbondedAmount: BigNumber; + withdrawableAmount: BigNumber; + shieldedAmount: BigNumber; + totalAmount: BigNumber; +}; + +export const useBalances = (): useBalancesOutput => { + const totalStakedBalance = useAtomValue(getStakingTotalAtom); + const totalAccountBalance = useAtomValue(accountBalanceAtom); + + const { + data: balance, + isLoading: isFetchingBalance, + isSuccess: isBalanceLoaded, + } = totalAccountBalance; + + const { + data: stakeBalance, + isLoading: isFetchingStaking, + isSuccess: isStakedBalanceLoaded, + } = totalStakedBalance; + + const availableAmount = new BigNumber(balance || 0); + const bondedAmount = new BigNumber(stakeBalance?.totalBonded || 0); + const unbondedAmount = new BigNumber(stakeBalance?.totalUnbonded || 0); + const withdrawableAmount = new BigNumber( + stakeBalance?.totalWithdrawable || 0 + ); + const shieldedAmount = new BigNumber(0); + const totalAmount = BigNumber.sum( + availableAmount, + bondedAmount, + unbondedAmount, + withdrawableAmount, + shieldedAmount + ); + + return { + isLoading: isFetchingStaking || isFetchingBalance, + isSuccess: isBalanceLoaded && isStakedBalanceLoaded, + stakeQuery: totalStakedBalance, + balanceQuery: totalAccountBalance, + availableAmount, + bondedAmount, + unbondedAmount, + withdrawableAmount, + shieldedAmount, + totalAmount, + }; +}; diff --git a/apps/namadillo/src/hooks/useStakeModule.ts b/apps/namadillo/src/hooks/useStakeModule.ts index ef8b1e4da..6ec933603 100644 --- a/apps/namadillo/src/hooks/useStakeModule.ts +++ b/apps/namadillo/src/hooks/useStakeModule.ts @@ -4,7 +4,7 @@ import { myValidatorsAtom } from "atoms/validators"; import BigNumber from "bignumber.js"; import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; -import { AddressBalance, ChangeInStakingPosition, Validator } from "types"; +import { AddressBalance, Validator } from "types"; type UseStakeModuleProps = { account: Account | undefined; @@ -60,15 +60,6 @@ export const useStakeModule = ({ account }: UseStakeModuleProps) => { }); }; - const parseUpdatedAmounts = (): ChangeInStakingPosition[] => { - return Object.keys(updatedAmountByAddress) - .map((validatorAddress) => ({ - validatorId: validatorAddress, - amount: updatedAmountByAddress[validatorAddress], - })) - .filter((entries) => entries.amount.gt(0)); - }; - useEffect(() => { if (!myValidators.isSuccess || !account) return; @@ -85,7 +76,6 @@ export const useStakeModule = ({ account }: UseStakeModuleProps) => { totalNamAfterStaking, totalStakedAmount, totalAmountToDelegate, - parseUpdatedAmounts, myValidators, stakedAmountByAddress, updatedAmountByAddress, diff --git a/apps/namadillo/src/hooks/useTransaction.tsx b/apps/namadillo/src/hooks/useTransaction.tsx new file mode 100644 index 000000000..e3cc05766 --- /dev/null +++ b/apps/namadillo/src/hooks/useTransaction.tsx @@ -0,0 +1,153 @@ +import { defaultAccountAtom } from "atoms/accounts"; +import { defaultGasConfigFamily } from "atoms/fees"; +import { + createNotificationId, + dispatchToastNotificationAtom, +} from "atoms/notifications"; +import invariant from "invariant"; +import { Atom, useAtomValue, useSetAtom } from "jotai"; +import { AtomWithMutationResult } from "jotai-tanstack-query"; +import { broadcastTx, TransactionPair } from "lib/query"; +import { BuildTxAtomParams, GasConfig, ToastNotification } from "types"; +import { TransactionEventsClasses } from "types/events"; + +type AtomType = Atom< + AtomWithMutationResult< + TransactionPair | undefined, + unknown, + BuildTxAtomParams, + unknown + > +>; + +type PartialNotification = Pick; + +export type useTransactionProps = { + params: T[]; + createTxAtom: AtomType; + eventType: TransactionEventsClasses; + parsePendingTxNotification?: (tx: TransactionPair) => PartialNotification; + parseErrorTxNotification?: () => PartialNotification; + onSigned?: (tx: TransactionPair) => void; + onError?: (err: unknown) => void; + onSuccess?: (tx: TransactionPair) => void; +}; + +type useTransactionOutput = { + execute: () => void; + isEnabled: boolean; + isPending: boolean; + isSuccess: boolean; + gasConfig: GasConfig | undefined; +}; + +export const useTransaction = ({ + params, + createTxAtom, + eventType, + parsePendingTxNotification, + parseErrorTxNotification, + onSuccess, + onError, +}: useTransactionProps): useTransactionOutput => { + const { data: account } = useAtomValue(defaultAccountAtom); + const { + mutateAsync: buildTx, + isPending, + isSuccess, + } = useAtomValue(createTxAtom); + + const dispatchNotification = useSetAtom(dispatchToastNotificationAtom); + + const gasConfig = useAtomValue( + defaultGasConfigFamily( + Array(Object.keys(params || {}).length).fill(eventType) + ) + ); + + const validate = (): void => { + invariant(gasConfig.data, "Gas config not loaded"); + invariant( + account?.address, + "Extension not connected or no account is selected" + ); + }; + + const broadcast = (tx: TransactionPair): void => { + tx.signedTxs.forEach((signedTx) => { + broadcastTx( + tx.encodedTxData, + signedTx, + tx.encodedTxData.meta?.props, + eventType + ); + }); + }; + + const dispatchPendingTxNotification = ( + tx: TransactionPair, + notification: PartialNotification + ): void => { + dispatchNotification({ + ...notification, + id: createNotificationId(tx.encodedTxData.txs), + type: "pending", + }); + }; + + const dispatchErrorNotification = ( + error: unknown, + notification: PartialNotification + ): void => { + dispatchNotification({ + ...notification, + id: createNotificationId(), + details: error instanceof Error ? error.message : undefined, + type: "error", + }); + }; + + const execute = async (): Promise => { + try { + validate(); + const tx = await buildTx({ + params, + gasConfig: gasConfig.data!, + account, + }); + + if (!tx) throw "Error: invalid TX created by buildTx"; + broadcast(tx); + + if (parsePendingTxNotification) { + dispatchPendingTxNotification(tx, parsePendingTxNotification(tx)); + } + + if (onSuccess) { + onSuccess(tx); + } + } catch (err) { + if (parseErrorTxNotification) { + dispatchErrorNotification(err, parseErrorTxNotification()); + } + + if (onError) { + onError(err); + } + } + }; + + return { + execute, + isPending, + isSuccess, + gasConfig: gasConfig.data, + isEnabled: Boolean( + !isPending && + !isSuccess && + gasConfig?.data && + account && + params.length > 0 + ), + }; +}; diff --git a/apps/namadillo/src/hooks/useTransactionCallbacks.tsx b/apps/namadillo/src/hooks/useTransactionCallbacks.tsx index 4b837cd6b..7d736b189 100644 --- a/apps/namadillo/src/hooks/useTransactionCallbacks.tsx +++ b/apps/namadillo/src/hooks/useTransactionCallbacks.tsx @@ -1,16 +1,19 @@ import { accountBalanceAtom } from "atoms/accounts"; import { shouldUpdateBalanceAtom, shouldUpdateProposalAtom } from "atoms/etc"; +import { claimableRewardsAtom } from "atoms/staking"; import { useAtomValue, useSetAtom } from "jotai"; import { useTransactionEventListener } from "utils"; export const useTransactionCallback = (): void => { const { refetch: refetchBalances } = useAtomValue(accountBalanceAtom); + const { refetch: refetchRewards } = useAtomValue(claimableRewardsAtom); const shouldUpdateBalance = useSetAtom(shouldUpdateBalanceAtom); const onBalanceUpdate = (): void => { // TODO: refactor this after event subscription is enabled on indexer shouldUpdateBalance(true); refetchBalances(); + refetchRewards(); const timePolling = 6 * 1000; setTimeout(() => shouldUpdateBalance(false), timePolling); @@ -19,6 +22,8 @@ export const useTransactionCallback = (): void => { useTransactionEventListener("Bond.Success", onBalanceUpdate); useTransactionEventListener("Unbond.Success", onBalanceUpdate); useTransactionEventListener("Withdraw.Success", onBalanceUpdate); + useTransactionEventListener("Redelegate.Success", onBalanceUpdate); + useTransactionEventListener("ClaimRewards.Success", onBalanceUpdate); const shouldUpdateProposal = useSetAtom(shouldUpdateProposalAtom); diff --git a/apps/namadillo/src/hooks/useTransactionNotifications.tsx b/apps/namadillo/src/hooks/useTransactionNotifications.tsx index 1a463bfc8..c6c79f121 100644 --- a/apps/namadillo/src/hooks/useTransactionNotifications.tsx +++ b/apps/namadillo/src/hooks/useTransactionNotifications.tsx @@ -238,7 +238,7 @@ export const useTransactionNotifications = (): void => { }); }); - useTransactionEventListener("ReDelegate.Error", (e) => { + useTransactionEventListener("Redelegate.Error", (e) => { const { id, total } = parseTxsData(e.detail.tx, e.detail.data); clearPendingNotifications(id); dispatchNotification({ @@ -258,7 +258,7 @@ export const useTransactionNotifications = (): void => { }); }); - useTransactionEventListener("ReDelegate.Success", (e) => { + useTransactionEventListener("Redelegate.Success", (e) => { const { id, total } = parseTxsData(e.detail.tx, e.detail.data); clearPendingNotifications(id); dispatchNotification({ @@ -275,7 +275,7 @@ export const useTransactionNotifications = (): void => { }); }); - useTransactionEventListener("ReDelegate.PartialSuccess", (e) => { + useTransactionEventListener("Redelegate.PartialSuccess", (e) => { const { id, total } = parseTxsData(e.detail.tx, e.detail.successData!); clearPendingNotifications(id); dispatchNotification({ @@ -296,6 +296,28 @@ export const useTransactionNotifications = (): void => { }); }); + useTransactionEventListener("ClaimRewards.Success", (e) => { + const id = createNotificationId(e.detail.tx); + clearPendingNotifications(id); + dispatchNotification({ + id, + title: "Claim Rewards", + description: `Your rewards have been successfully claimed and are now available for staking.`, + type: "success", + }); + }); + + useTransactionEventListener("ClaimRewards.Error", (e) => { + const id = createNotificationId(e.detail.tx); + clearPendingNotifications(id); + dispatchNotification({ + id, + title: "Claim Rewards", + description: `An error occurred while trying to claim your rewards.`, + type: "success", + }); + }); + useTransactionEventListener("VoteProposal.Error", (e) => { const id = createNotificationId(e.detail.tx); clearPendingNotifications(id); diff --git a/apps/namadillo/src/test-utils.tsx b/apps/namadillo/src/test-utils.tsx index 1999a3078..af9564f35 100644 --- a/apps/namadillo/src/test-utils.tsx +++ b/apps/namadillo/src/test-utils.tsx @@ -5,3 +5,24 @@ import { MemoryRouter } from "react-router-dom"; export const render = (element: ReactNode): RenderResult => { return rtlRender({element}); }; + +export const mockReactRouterDom = (pathname: string): void => { + const mockLocation = { pathname }; + jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => jest.fn(), + useLocation: () => mockLocation, + })); +}; + +export const mockJotai = (): void => { + jest.mock("jotai", () => ({ + useAtomValue: jest.fn(), + atom: jest.fn(), + })); + + jest.mock("jotai-tanstack-query", () => ({ + atomWithQuery: jest.fn(), + atomWithMutation: jest.fn(), + })); +}; diff --git a/apps/namadillo/src/theme.ts b/apps/namadillo/src/theme.ts new file mode 100644 index 000000000..331b016d7 --- /dev/null +++ b/apps/namadillo/src/theme.ts @@ -0,0 +1,28 @@ +export const colors = { + bond: "#00ffff", + balance: "#ffffff", + unbond: "#DD1599", + shielded: "#ffff00", + empty: "2F2F2F", +}; + +export const keyframes = { + niceSpin: { + "0%": { transform: "rotateZ(0)" }, + "25%, 90%": { transform: "rotateZ(180deg)" }, + "100%": { transform: "rotateZ(360deg)" }, + }, + loading: { + from: { + transform: "rotate(0turn)", + }, + to: { + transform: "rotate(1turn)", + }, + }, +}; + +export const animation = { + niceSpin: "niceSpin 1s ease-out infinite 1s", + loadingSpinner: "loading 1s ease infinite", +}; diff --git a/apps/namadillo/src/types.d.ts b/apps/namadillo/src/types.d.ts index 1d605dcb0..bcc61d059 100644 --- a/apps/namadillo/src/types.d.ts +++ b/apps/namadillo/src/types.d.ts @@ -3,7 +3,7 @@ import { Unbond as IndexerUnbond, ValidatorStatus, } from "@anomaorg/namada-indexer-client"; -import { ChainKey, ExtensionKey } from "@namada/types"; +import { ChainKey, ClaimRewardsMsgValue, ExtensionKey } from "@namada/types"; import BigNumber from "bignumber.js"; declare module "*.module.css" { @@ -127,10 +127,22 @@ export type RedelegateChange = { amount: BigNumber; }; +export type ClaimRewardsProps = { + account: Account; + params: ClaimRewardsMsgValue[]; + gasConfig: GasConfig; +}; + +export type BuildTxAtomParams = { + account: Account; + params: T[]; + gasConfig: GasConfig; +}; + export type TxKind = | "Bond" | "Unbond" - | "Redelegation" + | "Redelegate" | "Withdraw" | "ClaimRewards" | "VoteProposal" diff --git a/apps/namadillo/src/types/events.ts b/apps/namadillo/src/types/events.ts index 811004557..49de64e14 100644 --- a/apps/namadillo/src/types/events.ts +++ b/apps/namadillo/src/types/events.ts @@ -6,13 +6,9 @@ import { VoteProposalProps, WithdrawProps, } from "@namada/types"; +import { ClaimRewardsProps, TxKind } from "types"; -export type TransactionEventsClasses = - | "Bond" - | "Unbond" - | "ReDelegate" - | "Withdraw" - | "VoteProposal"; +export type TransactionEventsClasses = Partial; export type TransactionEventsStatus = | "Pending" @@ -48,11 +44,13 @@ declare global { "Unbond.Success": EventData; "Unbond.PartialSuccess": EventData; "Unbond.Error": EventData; - "ReDelegate.Success": EventData; - "ReDelegate.PartialSuccess": EventData; - "ReDelegate.Error": EventData; + "Redelegate.Success": EventData; + "Redelegate.PartialSuccess": EventData; + "Redelegate.Error": EventData; "Withdraw.Success": EventData; "Withdraw.Error": EventData; + "ClaimRewards.Success": EventData; + "ClaimRewards.Error": EventData; "VoteProposal.Success": EventData; "VoteProposal.Error": EventData; } diff --git a/apps/namadillo/src/utils/index.ts b/apps/namadillo/src/utils/index.ts index b24619f44..b6d16d159 100644 --- a/apps/namadillo/src/utils/index.ts +++ b/apps/namadillo/src/utils/index.ts @@ -1,4 +1,5 @@ import { ProposalStatus, ProposalTypeString } from "@namada/types"; +import BigNumber from "bignumber.js"; import * as fns from "date-fns"; import { DateTime } from "luxon"; import { useEffect } from "react"; @@ -57,6 +58,11 @@ export const secondsToDateString = (seconds: bigint): string => export const secondsToDateTimeString = (seconds: bigint): string => `${secondsToDateString(seconds)}, ${secondsToTimeString(seconds)}`; +export const sumBigNumberArray = (numbers: BigNumber[]): BigNumber => { + if (numbers.length === 0) return new BigNumber(0); + return BigNumber.sum(...numbers); +}; + export const secondsToTimeRemainingString = ( startTimeInSeconds: bigint, endTimeInSeconds: bigint diff --git a/apps/namadillo/tailwind.config.cjs b/apps/namadillo/tailwind.config.cjs index 8c77cd8a6..5d6b622d3 100644 --- a/apps/namadillo/tailwind.config.cjs +++ b/apps/namadillo/tailwind.config.cjs @@ -1,32 +1,17 @@ +import { animation, colors, keyframes } from "./src/theme"; + /** @type {import('tailwindcss').Config} */ module.exports = { content: [ "./src/**/*.{js,jsx,ts,tsx}", "../../packages/components/src/**/*.{js,ts,jsx,tsx}", ], - presets: [require("@namada/components/src/theme.ts")], + presets: [require("@namada/components/src/theme")], theme: { extend: { - keyframes: { - niceSpin: { - "0%": { transform: "rotateZ(0)" }, - "25%, 90%": { transform: "rotateZ(180deg)" }, - "100%": { transform: "rotateZ(360deg)" }, - }, - - loading: { - from: { - transform: "rotate(0turn)", - }, - to: { - transform: "rotate(1turn)", - }, - }, - }, - animation: { - niceSpin: "niceSpin 1s ease-out infinite 1s", - loadingSpinner: "loading 1s ease infinite", - }, + colors, + keyframes, + animation, }, }, plugins: [require("@tailwindcss/container-queries")], diff --git a/packages/components/src/PieChart.tsx b/packages/components/src/PieChart.tsx index 7a3f42a81..6205b2e2a 100644 --- a/packages/components/src/PieChart.tsx +++ b/packages/components/src/PieChart.tsx @@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge"; export type PieChartData = { value: number | BigNumber; color: string; + label?: string; }; type PieChartProps = Omit< @@ -69,6 +70,7 @@ export const PieChart = ({ strokeDasharray={`${segmentLength} ${length}`} r={radius - strokeWidth} stroke={dataItem.color} + aria-label={dataItem.label || ""} onMouseEnter={ onMouseEnter ? () => onMouseEnter(dataItem, dataItem.originalIndex) diff --git a/packages/components/src/SkeletonLoading.tsx b/packages/components/src/SkeletonLoading.tsx index cd132c5fc..29d79308e 100644 --- a/packages/components/src/SkeletonLoading.tsx +++ b/packages/components/src/SkeletonLoading.tsx @@ -14,6 +14,7 @@ export const SkeletonLoading = ({ const { className, ...rest } = props; return ( = 28.1.0" + react: ^17.0.0 || ^18.0.0 + checksum: f71a46b2fb35dc25df714005b2d36f82287ab518647eddbce9c9baa923478da2a25f3fd358703db01c52630f2733a375219d3d0896965856b3940788a2f2f68e + languageName: node + linkType: hard + "jest-util@npm:^29.0.0, jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0"