Skip to content

Commit

Permalink
feat(wallet): implement fee granter as a global setting
Browse files Browse the repository at this point in the history
closes #219
  • Loading branch information
ygrishajev committed Jun 13, 2024
1 parent 82d385c commit d8da6a3
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Props> = ({ allowance }) => {
export const AllowanceGrantedRow: React.FunctionComponent<Props> = ({ allowance, selected, onSelect }) => {
const limit = allowance?.allowance.spend_limit[0];
return (
<TableRow className="[&>td]:px-2 [&>td]:py-1">
<TableCell>{getAllowanceTitleByType(allowance)}</TableCell>
<TableCell>
<Address address={allowance.granter} isCopyable />
<Checkbox className="ml-2" checked={selected} onCheckedChange={typeof onSelect === "function" ? checked => checked && onSelect() : undefined} />
</TableCell>
<TableCell>{getAllowanceTitleByType(allowance)}</TableCell>
<TableCell>{allowance.granter && <Address address={allowance.granter} isCopyable />}</TableCell>
<TableCell>
<AKTAmount uakt={coinToUDenom(allowance.allowance.spend_limit[0])} /> AKT
</TableCell>
<TableCell align="right">
<FormattedTime year="numeric" month={"numeric"} day={"numeric"} value={allowance.allowance.expiration} />
{limit && <AKTAmount uakt={coinToUDenom(limit)} />}
{limit && "AKT"}
</TableCell>
<TableCell align="right">{<FormattedTime year="numeric" month={"numeric"} day={"numeric"} value={allowance.allowance.expiration} />}</TableCell>
</TableRow>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { FC } from "react";

import { useAllowance } from "@src/hooks/useAllowance";

export const AllowanceWatcher: FC = () => {
useAllowance();
return null;
};
29 changes: 24 additions & 5 deletions apps/deploy-web/src/components/authorizations/Authorizations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -261,6 +262,7 @@ export const Authorizations: React.FunctionComponent = () => {
<Table>
<TableHeader>
<TableRow>
<TableHead>Default</TableHead>
<TableHead>Type</TableHead>
<TableHead>Grantee</TableHead>
<TableHead>Spending Limit</TableHead>
Expand All @@ -269,8 +271,25 @@ export const Authorizations: React.FunctionComponent = () => {
</TableHeader>

<TableBody>
{!!allowancesGranted && (
<AllowanceGrantedRow
key={address}
allowance={{
granter: "",
grantee: "",
allowance: { "@type": "$CONNECTED_WALLET", expiration: "", spend_limit: [] }
}}
onSelect={() => setDefault(undefined)}
selected={!defaultAllowance}
/>
)}
{allowancesGranted.map(allowance => (
<AllowanceGrantedRow key={allowance.granter} allowance={allowance} />
<AllowanceGrantedRow
key={allowance.granter}
allowance={allowance}
onSelect={() => setDefault(allowance.granter)}
selected={defaultAllowance === allowance.granter}
/>
))}
</TableBody>
</Table>
Expand Down
40 changes: 5 additions & 35 deletions apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
"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";
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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions apps/deploy-web/src/hooks/useAllowance.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("fee-granters") || "{}") : {};

const AllowanceNotificationMessage: FC = () => (
<>
You can update default fee granter in
<Link href="/settings/authorizations" className="inline-flex items-center space-x-2 !text-white">
<span>Authorizations Settings</span>
<OpenNewWindow className="text-xs" />
</Link>
</>
);

export const useAllowance = () => {
const { address } = useWallet();
const [defaultFeeGranter, setDefaultFeeGranter] = useLocalStorage<string | undefined>("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(<Snackbar iconVariant="info" title="New fee allowance granted" subTitle={<AllowanceNotificationMessage />} />, {
variant: "info"
});
}

if (removed.length) {
enqueueSnackbar(<Snackbar iconVariant="warning" title="Some fee allowance is revoked or expired" subTitle={<AllowanceNotificationMessage />} />, {
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]
);
};
10 changes: 10 additions & 0 deletions apps/deploy-web/src/hooks/useWhen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useEffect } from "react";

export function useWhen<T>(condition: T, run: () => void, deps: unknown[] = []): void {
return useEffect(() => {
if (condition) {
run();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [condition, ...deps]);
}
2 changes: 2 additions & 0 deletions apps/deploy-web/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,6 +72,7 @@ const App: React.FunctionComponent<Props> = props => {
<BackgroundTaskProvider>
<TemplatesProvider>
<LocalNoteProvider>
<AllowanceWatcher />
<GoogleAnalytics />
<Component {...pageProps} />
</LocalNoteProvider>
Expand Down
3 changes: 2 additions & 1 deletion apps/deploy-web/src/utils/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down

0 comments on commit d8da6a3

Please sign in to comment.