From d8da6a30887d0168f6fc9a753e7f2ed5cd1f516e Mon Sep 17 00:00:00 2001 From: Yaroslav Grishajev Date: Thu, 13 Jun 2024 14:48:35 +0200 Subject: [PATCH] feat(wallet): implement fee granter as a global setting closes #219 --- .../authorizations/AllowanceGrantedRow.tsx | 18 ++-- .../authorizations/AllowanceWatcher.tsx | 8 ++ .../authorizations/Authorizations.tsx | 29 ++++-- .../context/WalletProvider/WalletProvider.tsx | 40 ++------- apps/deploy-web/src/hooks/useAllowance.tsx | 89 +++++++++++++++++++ apps/deploy-web/src/hooks/useWhen.ts | 10 +++ apps/deploy-web/src/pages/_app.tsx | 2 + apps/deploy-web/src/utils/grants.ts | 3 +- 8 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 apps/deploy-web/src/components/authorizations/AllowanceWatcher.tsx create mode 100644 apps/deploy-web/src/hooks/useAllowance.tsx create mode 100644 apps/deploy-web/src/hooks/useWhen.ts diff --git a/apps/deploy-web/src/components/authorizations/AllowanceGrantedRow.tsx b/apps/deploy-web/src/components/authorizations/AllowanceGrantedRow.tsx index 85fae1fe7..a1ec1824a 100644 --- a/apps/deploy-web/src/components/authorizations/AllowanceGrantedRow.tsx +++ b/apps/deploy-web/src/components/authorizations/AllowanceGrantedRow.tsx @@ -4,6 +4,7 @@ import { FormattedTime } from "react-intl"; import { Address } from "@src/components/shared/Address"; import { AKTAmount } from "@src/components/shared/AKTAmount"; +import { Checkbox } from "@src/components/ui/checkbox"; import { TableCell, TableRow } from "@src/components/ui/table"; import { AllowanceType } from "@src/types/grant"; import { getAllowanceTitleByType } from "@src/utils/grants"; @@ -12,21 +13,24 @@ import { coinToUDenom } from "@src/utils/priceUtils"; type Props = { allowance: AllowanceType; children?: ReactNode; + onSelect?: () => void; + selected?: boolean; }; -export const AllowanceGrantedRow: React.FunctionComponent = ({ allowance }) => { +export const AllowanceGrantedRow: React.FunctionComponent = ({ allowance, selected, onSelect }) => { + const limit = allowance?.allowance.spend_limit[0]; return ( - {getAllowanceTitleByType(allowance)} -
+ checked && onSelect() : undefined} /> + {getAllowanceTitleByType(allowance)} + {allowance.granter &&
} - AKT - - - + {limit && } + {limit && "AKT"} + {} ); }; diff --git a/apps/deploy-web/src/components/authorizations/AllowanceWatcher.tsx b/apps/deploy-web/src/components/authorizations/AllowanceWatcher.tsx new file mode 100644 index 000000000..67e356c4d --- /dev/null +++ b/apps/deploy-web/src/components/authorizations/AllowanceWatcher.tsx @@ -0,0 +1,8 @@ +import { FC } from "react"; + +import { useAllowance } from "@src/hooks/useAllowance"; + +export const AllowanceWatcher: FC = () => { + useAllowance(); + return null; +}; diff --git a/apps/deploy-web/src/components/authorizations/Authorizations.tsx b/apps/deploy-web/src/components/authorizations/Authorizations.tsx index 1e0e6b2bc..35301511b 100644 --- a/apps/deploy-web/src/components/authorizations/Authorizations.tsx +++ b/apps/deploy-web/src/components/authorizations/Authorizations.tsx @@ -10,7 +10,8 @@ import Spinner from "@src/components/shared/Spinner"; import { Button } from "@akashnetwork/ui/components"; import { Table, TableBody, TableHead, TableHeader, TableRow } from "@src/components/ui/table"; import { useWallet } from "@src/context/WalletProvider"; -import { useAllowancesGranted, useAllowancesIssued, useGranteeGrants, useGranterGrants } from "@src/queries/useGrantsQuery"; +import { useAllowance } from "@src/hooks/useAllowance"; +import { useAllowancesIssued, useGranteeGrants, useGranterGrants } from "@src/queries/useGrantsQuery"; import { AllowanceType, GrantType } from "@src/types/grant"; import { averageBlockTime } from "@src/utils/priceUtils"; import { TransactionMessageData } from "@src/utils/TransactionMessageData"; @@ -46,9 +47,9 @@ export const Authorizations: React.FunctionComponent = () => { const { data: allowancesIssued, isLoading: isLoadingAllowancesIssued } = useAllowancesIssued(address, { refetchInterval: isRefreshing === "allowancesIssued" ? refreshingInterval : defaultRefetchInterval }); - const { data: allowancesGranted, isLoading: isLoadingAllowancesGranted } = useAllowancesGranted(address, { - refetchInterval: isRefreshing === "allowancesGranted" ? refreshingInterval : defaultRefetchInterval - }); + const { + fee: { all: allowancesGranted, isLoading: isLoadingAllowancesGranted, setDefault, default: defaultAllowance } + } = useAllowance(); useEffect(() => { let timeout: NodeJS.Timeout; @@ -261,6 +262,7 @@ export const Authorizations: React.FunctionComponent = () => { + Default Type Grantee Spending Limit @@ -269,8 +271,25 @@ export const Authorizations: React.FunctionComponent = () => { + {!!allowancesGranted && ( + setDefault(undefined)} + selected={!defaultAllowance} + /> + )} {allowancesGranted.map(allowance => ( - + setDefault(allowance.granter)} + selected={defaultAllowance === allowance.granter} + /> ))}
diff --git a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx index 799ce9493..546ac51d8 100644 --- a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx +++ b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx @@ -1,12 +1,10 @@ "use client"; -import React, { useMemo, useRef } from "react"; +import React, { useRef } from "react"; import { useEffect, useState } from "react"; import { EncodeObject } from "@cosmjs/proto-signing"; import { SigningStargateClient } from "@cosmjs/stargate"; import { useManager } from "@cosmos-kit/react"; import axios from "axios"; -import isAfter from "date-fns/isAfter"; -import parseISO from "date-fns/parseISO"; import { OpenNewWindow } from "iconoir-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -14,17 +12,13 @@ import { event } from "nextjs-google-analytics"; import { SnackbarKey, useSnackbar } from "notistack"; import { TransactionModal } from "@src/components/layout/TransactionModal"; -import { SelectOption } from "@src/components/shared/Popup"; import { Snackbar } from "@src/components/shared/Snackbar"; -import { usePopup } from "@src/context/PopupProvider/PopupProvider"; +import { useAllowance } from "@src/hooks/useAllowance"; import { useUsdcDenom } from "@src/hooks/useDenom"; import { getSelectedNetwork, useSelectedNetwork } from "@src/hooks/useSelectedNetwork"; -import { useAllowancesGranted } from "@src/queries/useGrantsQuery"; import { AnalyticsEvents } from "@src/utils/analytics"; import { STATS_APP_URL, uAktDenom } from "@src/utils/constants"; import { customRegistry } from "@src/utils/customRegistry"; -import { udenomToDenom } from "@src/utils/mathHelpers"; -import { coinToUDenom } from "@src/utils/priceUtils"; import { UrlService } from "@src/utils/urlUtils"; import { LocalWalletDataType } from "@src/utils/walletUtils"; import { useSelectedChain } from "../CustomChainProvider"; @@ -62,32 +56,9 @@ export const WalletProvider = ({ children }) => { const usdcIbcDenom = useUsdcDenom(); const { disconnect, getOfflineSigner, isWalletConnected, address: walletAddress, connect, username, estimateFee, sign, broadcast } = useSelectedChain(); const { addEndpoints } = useManager(); - const { data: allowancesGranted } = useAllowancesGranted(walletAddress); - - const feeGranters = useMemo(() => { - if (!walletAddress || !allowancesGranted) { - return; - } - - const connectedWallet: SelectOption = { text: "Connected Wallet", value: walletAddress }; - const options: SelectOption[] = allowancesGranted.reduce( - (acc, grant, index) => { - if (isAfter(parseISO(grant.allowance.expiration), new Date())) { - acc.push({ - text: `${grant.granter} (${udenomToDenom(coinToUDenom(grant.allowance.spend_limit[0]), 6)} AKT)`, - value: grant.granter, - selected: index === 0 - }); - } - - return acc; - }, - [connectedWallet] - ); - - return options?.length > 1 ? options : undefined; - }, [allowancesGranted, walletAddress]); - const { select } = usePopup(); + const { + fee: { default: feeGranter } + } = useAllowance(); useEffect(() => { if (!settings.apiEndpoint || !settings.rpcEndpoint) return; @@ -193,7 +164,6 @@ export const WalletProvider = ({ children }) => { let pendingSnackbarKey: SnackbarKey | null = null; try { const estimatedFees = await estimateFee(msgs); - const feeGranter = feeGranters && (await select({ title: "Select fee granter", options: feeGranters })); const txRaw = await sign(msgs, { ...estimatedFees, granter: feeGranter diff --git a/apps/deploy-web/src/hooks/useAllowance.tsx b/apps/deploy-web/src/hooks/useAllowance.tsx new file mode 100644 index 000000000..0c589e978 --- /dev/null +++ b/apps/deploy-web/src/hooks/useAllowance.tsx @@ -0,0 +1,89 @@ +import React, { FC, useMemo } from "react"; +import isAfter from "date-fns/isAfter"; +import parseISO from "date-fns/parseISO"; +import { OpenNewWindow } from "iconoir-react"; +import difference from "lodash/difference"; +import Link from "next/link"; +import { useSnackbar } from "notistack"; +import { useLocalStorage } from "usehooks-ts"; + +import { Snackbar } from "@src/components/shared/Snackbar"; +import { useWallet } from "@src/context/WalletProvider"; +import { useWhen } from "@src/hooks/useWhen"; +import { useAllowancesGranted } from "@src/queries/useGrantsQuery"; + +const persisted: Record = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("fee-granters") || "{}") : {}; + +const AllowanceNotificationMessage: FC = () => ( + <> + You can update default fee granter in + + Authorizations Settings + + + +); + +export const useAllowance = () => { + const { address } = useWallet(); + const [defaultFeeGranter, setDefaultFeeGranter] = useLocalStorage("default-fee-granter", undefined); + const { data: allFeeGranters, isLoading, isFetched } = useAllowancesGranted(address); + const { enqueueSnackbar } = useSnackbar(); + + const actualAddresses = useMemo(() => { + if (!address || !allFeeGranters) { + return []; + } + + return allFeeGranters.reduce((acc, grant) => { + if (isAfter(parseISO(grant.allowance.expiration), new Date())) { + acc.push(grant.granter); + } + + return acc; + }, [] as string[]); + }, [allFeeGranters, address]); + + useWhen( + isFetched && address, + () => { + const persistedAddresses = persisted[address] || []; + const added = difference(actualAddresses, persistedAddresses); + const removed = difference(persistedAddresses, actualAddresses); + + if (added.length || removed.length) { + persisted[address] = actualAddresses; + localStorage.setItem(`fee-granters`, JSON.stringify(persisted)); + } + + if (added.length) { + enqueueSnackbar(} />, { + variant: "info" + }); + } + + if (removed.length) { + enqueueSnackbar(} />, { + variant: "warning" + }); + } + + if (defaultFeeGranter && removed.includes(defaultFeeGranter)) { + setDefaultFeeGranter(undefined); + } + }, + [actualAddresses, persisted] + ); + + return useMemo( + () => ({ + fee: { + all: allFeeGranters, + default: defaultFeeGranter, + setDefault: setDefaultFeeGranter, + isLoading + } + }), + [defaultFeeGranter, setDefaultFeeGranter, allFeeGranters, isLoading] + ); +}; diff --git a/apps/deploy-web/src/hooks/useWhen.ts b/apps/deploy-web/src/hooks/useWhen.ts new file mode 100644 index 000000000..66d9a2278 --- /dev/null +++ b/apps/deploy-web/src/hooks/useWhen.ts @@ -0,0 +1,10 @@ +import { useEffect } from "react"; + +export function useWhen(condition: T, run: () => void, deps: unknown[] = []): void { + return useEffect(() => { + if (condition) { + run(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [condition, ...deps]); +} diff --git a/apps/deploy-web/src/pages/_app.tsx b/apps/deploy-web/src/pages/_app.tsx index 0b54da33a..29453a7c2 100644 --- a/apps/deploy-web/src/pages/_app.tsx +++ b/apps/deploy-web/src/pages/_app.tsx @@ -9,6 +9,7 @@ import Router from "next/router"; import { ThemeProvider } from "next-themes"; import NProgress from "nprogress"; //nprogress module +import { AllowanceWatcher } from "@src/components/authorizations/AllowanceWatcher"; import GoogleAnalytics from "@src/components/layout/CustomGoogleAnalytics"; import { CustomIntlProvider } from "@src/components/layout/CustomIntlProvider"; import { PageHead } from "@src/components/layout/PageHead"; @@ -71,6 +72,7 @@ const App: React.FunctionComponent = props => { + diff --git a/apps/deploy-web/src/utils/grants.ts b/apps/deploy-web/src/utils/grants.ts index 68c894965..cf3909911 100644 --- a/apps/deploy-web/src/utils/grants.ts +++ b/apps/deploy-web/src/utils/grants.ts @@ -6,7 +6,8 @@ export const getAllowanceTitleByType = (allowance: AllowanceType) => { return "Basic"; case "/cosmos.feegrant.v1beta1.PeriodicAllowance": return "Periodic"; - + case "$CONNECTED_WALLET": + return "Connected Wallet"; default: return "Unknown"; }