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/components/shared/Popup.tsx b/apps/deploy-web/src/components/shared/Popup.tsx index 68e954653..d00737413 100644 --- a/apps/deploy-web/src/components/shared/Popup.tsx +++ b/apps/deploy-web/src/components/shared/Popup.tsx @@ -1,7 +1,9 @@ import * as React from "react"; +import { useMemo } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { DialogProps } from "@radix-ui/react-dialog"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui/select"; import { cn } from "@src/utils/styleUtils"; import { Button, ButtonProps } from "@akashnetwork/ui/components"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle as _DialogTitle } from "../ui/dialog"; @@ -32,6 +34,21 @@ type CustomPrompt = { actions: ActionButton[]; }; +export type SelectOption = { + text: string; + value: string; + selected?: boolean; + disabled?: boolean; +}; + +export type SelectProps = { + variant: "select"; + options: SelectOption[]; + placeholder?: string; + onValidate: (value: string | undefined) => void; + onCancel: () => void; +}; + export type TOnCloseHandler = { (event: any, reason: "backdropClick" | "escapeKeyDown" | "action"): void; }; @@ -58,7 +75,7 @@ export type ActionButton = ButtonProps & { isLoading?: boolean; }; -export type PopupProps = (MessageProps | ConfirmProps | PromptProps | CustomPrompt) & CommonProps; +export type PopupProps = (MessageProps | ConfirmProps | PromptProps | CustomPrompt | SelectProps) & CommonProps; export interface DialogTitleProps { children: React.ReactNode; @@ -78,6 +95,8 @@ export const DialogTitle = (props: DialogTitleProps) => { export function Popup(props: React.PropsWithChildren) { const [promptInput, setPromptInput] = React.useState(""); + const initialOption = useMemo(() => (props.variant === "select" ? props.options.find(option => option.selected)?.value : undefined), [props]); + const [selectOption, setSelectOption] = React.useState(initialOption); const component = [] as JSX.Element[]; const onClose: TOnCloseHandler = (event, reason) => { @@ -97,7 +116,30 @@ export function Popup(props: React.PropsWithChildren) { component.push({props.title}); } - if (props.message && props.variant !== "prompt") { + if (props.variant === "select") { + component.push( + + {props.message} + {props.variant === "select" ? ( + + ) : null} + + + ); + } else if (props.message && props.variant !== "prompt") { component.push( {props.message} @@ -215,6 +257,32 @@ export function Popup(props: React.PropsWithChildren) { ); break; } + case "select": { + component.push( + + + + + ); + break; + } } /** diff --git a/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx b/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx index 57d8f0843..a0480ab0d 100644 --- a/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx +++ b/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx @@ -1,13 +1,15 @@ import React, { useCallback, useMemo, useState } from "react"; import { firstValueFrom, Subject } from "rxjs"; -import { CommonProps, ConfirmProps, Popup, PopupProps } from "@src/components/shared/Popup"; +import { CommonProps, ConfirmProps, Popup, PopupProps, SelectOption, SelectProps } from "@src/components/shared/Popup"; import type { FCWithChildren } from "@src/types/component"; type ConfirmPopupProps = string | (Omit & Omit); +type SelectPopupProps = Omit & Omit; type PopupProviderContext = { confirm: (messageOrProps: ConfirmPopupProps) => Promise; + select: (props: SelectPopupProps) => Promise; }; const PopupContext = React.createContext(undefined); @@ -15,7 +17,7 @@ const PopupContext = React.createContext(undef export const PopupProvider: FCWithChildren = ({ children }) => { const [popupProps, setPopupProps] = useState(); - const confirm = useCallback( + const confirm: PopupProviderContext["confirm"] = useCallback( (messageOrProps: ConfirmPopupProps) => { let subject: Subject | undefined = new Subject(); @@ -45,7 +47,42 @@ export const PopupProvider: FCWithChildren = ({ children }) => { [setPopupProps] ); - const context = useMemo(() => ({ confirm }), [confirm]); + const select: PopupProviderContext["select"] = useCallback( + props => { + let subject: Subject | undefined = new Subject(); + + const reject = () => { + if (subject) { + subject.next(undefined); + subject.complete(); + setPopupProps(undefined); + subject = undefined; + } + }; + + setPopupProps({ + title: "Confirm", + ...props, + open: true, + variant: "select", + onValidate: value => { + if (subject) { + subject.next(value); + subject.complete(); + setPopupProps(undefined); + subject = undefined; + } + }, + onCancel: reject, + onClose: reject + }); + + return firstValueFrom(subject); + }, + [setPopupProps] + ); + + const context = useMemo(() => ({ confirm, select }), [confirm]); return ( diff --git a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx index 70091110f..546ac51d8 100644 --- a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx +++ b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx @@ -13,6 +13,7 @@ import { SnackbarKey, useSnackbar } from "notistack"; import { TransactionModal } from "@src/components/layout/TransactionModal"; import { Snackbar } from "@src/components/shared/Snackbar"; +import { useAllowance } from "@src/hooks/useAllowance"; import { useUsdcDenom } from "@src/hooks/useDenom"; import { getSelectedNetwork, useSelectedNetwork } from "@src/hooks/useSelectedNetwork"; import { AnalyticsEvents } from "@src/utils/analytics"; @@ -55,6 +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 { + fee: { default: feeGranter } + } = useAllowance(); useEffect(() => { if (!settings.apiEndpoint || !settings.rpcEndpoint) return; @@ -160,8 +164,10 @@ export const WalletProvider = ({ children }) => { let pendingSnackbarKey: SnackbarKey | null = null; try { const estimatedFees = await estimateFee(msgs); - - const txRaw = await sign(msgs, estimatedFees); + const txRaw = await sign(msgs, { + ...estimatedFees, + granter: feeGranter + }); setIsWaitingForApproval(false); setIsBroadcastingTx(true); 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 be57dbd93..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"; @@ -64,22 +65,23 @@ const App: React.FunctionComponent = props => { - - - - - - - - + + + + + + + + + - - - - - - - + + + + + + + diff --git a/apps/deploy-web/src/queries/useGrantsQuery.ts b/apps/deploy-web/src/queries/useGrantsQuery.ts index 699f62ed5..e2bc1cfab 100644 --- a/apps/deploy-web/src/queries/useGrantsQuery.ts +++ b/apps/deploy-web/src/queries/useGrantsQuery.ts @@ -1,7 +1,9 @@ -import { useQuery } from "react-query"; +import { QueryObserverResult, useQuery } from "react-query"; import axios from "axios"; import { useSettings } from "@src/context/SettingsProvider"; +import { Coin } from "@src/types"; +import { AllowanceType } from "@src/types/grant"; import { ApiUrlService } from "@src/utils/apiUtils"; import { QueryKeys } from "./queryKeys"; @@ -67,8 +69,11 @@ async function getAllowancesGranted(apiEndpoint: string, address: string) { return response.data.allowances; } -export function useAllowancesGranted(address: string, options = {}) { +export function useAllowancesGranted(address?: string, options = {}): QueryObserverResult { const { settings } = useSettings(); - return useQuery(QueryKeys.getAllowancesGranted(address), () => getAllowancesGranted(settings.apiEndpoint, address), options); + return useQuery(address ? QueryKeys.getAllowancesGranted(address) : "", () => (address ? getAllowancesGranted(settings.apiEndpoint, address) : undefined), { + ...options, + enabled: !!address + }); } diff --git a/apps/deploy-web/src/utils/TransactionMessageData.ts b/apps/deploy-web/src/utils/TransactionMessageData.ts index 4aeff8d60..476eadf99 100644 --- a/apps/deploy-web/src/utils/TransactionMessageData.ts +++ b/apps/deploy-web/src/utils/TransactionMessageData.ts @@ -1,4 +1,3 @@ -import { longify } from "@cosmjs/stargate/build/queryclient"; import Long from "long"; import { BidDto } from "@src/types/deployment"; @@ -222,7 +221,7 @@ export class TransactionMessageData { ], expiration: expiration ? { - seconds: longify(Math.floor(expiration.getTime() / 1_000)) as unknown as Long, + seconds: Long.fromInt(Math.floor(expiration.getTime() / 1_000)) as unknown as Long, nanos: Math.floor((expiration.getTime() % 1_000) * 1_000_000) } : undefined 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"; }