Skip to content

Commit 2ce3d80

Browse files
committed
feat(wallet): implement fee granter as a global setting
closes #219
1 parent 4c540da commit 2ce3d80

File tree

7 files changed

+146
-48
lines changed

7 files changed

+146
-48
lines changed

apps/deploy-web/src/components/authorizations/AllowanceGrantedRow.tsx

+12-8
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,33 @@ import { FormattedTime } from "react-intl";
44

55
import { Address } from "@src/components/shared/Address";
66
import { AKTAmount } from "@src/components/shared/AKTAmount";
7+
import { Checkbox } from "@src/components/ui/checkbox";
78
import { TableCell, TableRow } from "@src/components/ui/table";
89
import { AllowanceType } from "@src/types/grant";
910
import { getAllowanceTitleByType } from "@src/utils/grants";
1011
import { coinToUDenom } from "@src/utils/priceUtils";
1112

1213
type Props = {
13-
allowance: AllowanceType;
14+
allowance: AllowanceType | { granter: "Connected Wallet" };
1415
children?: ReactNode;
16+
onSelect?: () => void;
17+
selected?: boolean;
1518
};
1619

17-
export const AllowanceGrantedRow: React.FunctionComponent<Props> = ({ allowance }) => {
20+
export const AllowanceGrantedRow: React.FunctionComponent<Props> = ({ allowance, selected, onSelect }) => {
21+
const isFull = "allowance" in allowance;
1822
return (
1923
<TableRow className="[&>td]:px-2 [&>td]:py-1">
20-
<TableCell>{getAllowanceTitleByType(allowance)}</TableCell>
2124
<TableCell>
22-
<Address address={allowance.granter} isCopyable />
25+
<Checkbox className="ml-2" checked={selected} onCheckedChange={typeof onSelect === "function" ? checked => checked && onSelect() : undefined} />
2326
</TableCell>
27+
<TableCell>{isFull ? getAllowanceTitleByType(allowance) : allowance.granter}</TableCell>
28+
<TableCell>{isFull && <Address address={allowance.granter} isCopyable />}</TableCell>
2429
<TableCell>
25-
<AKTAmount uakt={coinToUDenom(allowance.allowance.spend_limit[0])} /> AKT
26-
</TableCell>
27-
<TableCell align="right">
28-
<FormattedTime year="numeric" month={"numeric"} day={"numeric"} value={allowance.allowance.expiration} />
30+
{isFull && <AKTAmount uakt={coinToUDenom(allowance.allowance.spend_limit[0])} />}
31+
{isFull && "AKT"}
2932
</TableCell>
33+
<TableCell align="right">{isFull && <FormattedTime year="numeric" month={"numeric"} day={"numeric"} value={allowance.allowance.expiration} />}</TableCell>
3034
</TableRow>
3135
);
3236
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { FC } from "react";
2+
3+
import { useAllowance } from "@src/hooks/useAllowance";
4+
5+
export const AllowanceWatcher: FC = () => {
6+
useAllowance();
7+
return null;
8+
};

apps/deploy-web/src/components/authorizations/Authorizations.tsx

+20-5
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import Spinner from "@src/components/shared/Spinner";
1010
import { Button } from "@src/components/ui/button";
1111
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@src/components/ui/table";
1212
import { useWallet } from "@src/context/WalletProvider";
13-
import { useAllowancesGranted, useAllowancesIssued, useGranteeGrants, useGranterGrants } from "@src/queries/useGrantsQuery";
13+
import { useAllowance } from "@src/hooks/useAllowance";
14+
import { useAllowancesIssued, useGranteeGrants, useGranterGrants } from "@src/queries/useGrantsQuery";
1415
import { AllowanceType, GrantType } from "@src/types/grant";
1516
import { averageBlockTime } from "@src/utils/priceUtils";
1617
import { TransactionMessageData } from "@src/utils/TransactionMessageData";
@@ -46,9 +47,9 @@ export const Authorizations: React.FunctionComponent = () => {
4647
const { data: allowancesIssued, isLoading: isLoadingAllowancesIssued } = useAllowancesIssued(address, {
4748
refetchInterval: isRefreshing === "allowancesIssued" ? refreshingInterval : defaultRefetchInterval
4849
});
49-
const { data: allowancesGranted, isLoading: isLoadingAllowancesGranted } = useAllowancesGranted(address, {
50-
refetchInterval: isRefreshing === "allowancesGranted" ? refreshingInterval : defaultRefetchInterval
51-
});
50+
const {
51+
fee: { all: allowancesGranted, isLoading: isLoadingAllowancesGranted, setDefault, default: defaultAllowance }
52+
} = useAllowance();
5253

5354
useEffect(() => {
5455
let timeout: NodeJS.Timeout;
@@ -261,6 +262,7 @@ export const Authorizations: React.FunctionComponent = () => {
261262
<Table>
262263
<TableHeader>
263264
<TableRow>
265+
<TableHead>Default</TableHead>
264266
<TableHead>Type</TableHead>
265267
<TableHead>Grantee</TableHead>
266268
<TableHead>Spending Limit</TableHead>
@@ -269,8 +271,21 @@ export const Authorizations: React.FunctionComponent = () => {
269271
</TableHeader>
270272

271273
<TableBody>
274+
{!!allowancesGranted && (
275+
<AllowanceGrantedRow
276+
key={address}
277+
allowance={{ granter: "Connected Wallet" }}
278+
onSelect={() => setDefault(undefined)}
279+
selected={!defaultAllowance}
280+
/>
281+
)}
272282
{allowancesGranted.map(allowance => (
273-
<AllowanceGrantedRow key={allowance.granter} allowance={allowance} />
283+
<AllowanceGrantedRow
284+
key={allowance.granter}
285+
allowance={allowance}
286+
onSelect={() => setDefault(allowance.granter)}
287+
selected={defaultAllowance === allowance.granter}
288+
/>
274289
))}
275290
</TableBody>
276291
</Table>

apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx

+5-35
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
11
"use client";
2-
import React, { useMemo, useRef } from "react";
2+
import React, { useRef } from "react";
33
import { useEffect, useState } from "react";
44
import { EncodeObject } from "@cosmjs/proto-signing";
55
import { SigningStargateClient } from "@cosmjs/stargate";
66
import { useManager } from "@cosmos-kit/react";
77
import axios from "axios";
8-
import isAfter from "date-fns/isAfter";
9-
import parseISO from "date-fns/parseISO";
108
import { OpenNewWindow } from "iconoir-react";
119
import Link from "next/link";
1210
import { useRouter } from "next/navigation";
1311
import { event } from "nextjs-google-analytics";
1412
import { SnackbarKey, useSnackbar } from "notistack";
1513

1614
import { TransactionModal } from "@src/components/layout/TransactionModal";
17-
import { SelectOption } from "@src/components/shared/Popup";
1815
import { Snackbar } from "@src/components/shared/Snackbar";
19-
import { usePopup } from "@src/context/PopupProvider/PopupProvider";
16+
import { useAllowance } from "@src/hooks/useAllowance";
2017
import { useUsdcDenom } from "@src/hooks/useDenom";
2118
import { getSelectedNetwork, useSelectedNetwork } from "@src/hooks/useSelectedNetwork";
22-
import { useAllowancesGranted } from "@src/queries/useGrantsQuery";
2319
import { AnalyticsEvents } from "@src/utils/analytics";
2420
import { STATS_APP_URL, uAktDenom } from "@src/utils/constants";
2521
import { customRegistry } from "@src/utils/customRegistry";
26-
import { udenomToDenom } from "@src/utils/mathHelpers";
27-
import { coinToUDenom } from "@src/utils/priceUtils";
2822
import { UrlService } from "@src/utils/urlUtils";
2923
import { LocalWalletDataType } from "@src/utils/walletUtils";
3024
import { useSelectedChain } from "../CustomChainProvider";
@@ -62,32 +56,9 @@ export const WalletProvider = ({ children }) => {
6256
const usdcIbcDenom = useUsdcDenom();
6357
const { disconnect, getOfflineSigner, isWalletConnected, address: walletAddress, connect, username, estimateFee, sign, broadcast } = useSelectedChain();
6458
const { addEndpoints } = useManager();
65-
const { data: allowancesGranted } = useAllowancesGranted(walletAddress);
66-
67-
const feeGranters = useMemo(() => {
68-
if (!walletAddress || !allowancesGranted) {
69-
return;
70-
}
71-
72-
const connectedWallet: SelectOption = { text: "Connected Wallet", value: walletAddress };
73-
const options: SelectOption[] = allowancesGranted.reduce(
74-
(acc, grant, index) => {
75-
if (isAfter(parseISO(grant.allowance.expiration), new Date())) {
76-
acc.push({
77-
text: `${grant.granter} (${udenomToDenom(coinToUDenom(grant.allowance.spend_limit[0]), 6)} AKT)`,
78-
value: grant.granter,
79-
selected: index === 0
80-
});
81-
}
82-
83-
return acc;
84-
},
85-
[connectedWallet]
86-
);
87-
88-
return options?.length > 1 ? options : undefined;
89-
}, [allowancesGranted, walletAddress]);
90-
const { select } = usePopup();
59+
const {
60+
fee: { default: feeGranter }
61+
} = useAllowance();
9162

9263
useEffect(() => {
9364
if (!settings.apiEndpoint || !settings.rpcEndpoint) return;
@@ -193,7 +164,6 @@ export const WalletProvider = ({ children }) => {
193164
let pendingSnackbarKey: SnackbarKey | null = null;
194165
try {
195166
const estimatedFees = await estimateFee(msgs);
196-
const feeGranter = feeGranters && (await select({ title: "Select fee granter", options: feeGranters }));
197167
const txRaw = await sign(msgs, {
198168
...estimatedFees,
199169
granter: feeGranter
+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import React, { FC, useMemo } from "react";
2+
import isAfter from "date-fns/isAfter";
3+
import parseISO from "date-fns/parseISO";
4+
import { OpenNewWindow } from "iconoir-react";
5+
import difference from "lodash/difference";
6+
import Link from "next/link";
7+
import { useSnackbar } from "notistack";
8+
import { useLocalStorage } from "usehooks-ts";
9+
10+
import { Snackbar } from "@src/components/shared/Snackbar";
11+
import { useWallet } from "@src/context/WalletProvider";
12+
import { useWhen } from "@src/hooks/useWhen";
13+
import { useAllowancesGranted } from "@src/queries/useGrantsQuery";
14+
15+
const persisted: Record<string, string[]> = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("fee-granters") || "{}") : {};
16+
17+
const AllowanceNotificationMessage: FC = () => (
18+
<>
19+
You can update default fee granter in
20+
<Link href="/settings/authorizations" className="inline-flex items-center space-x-2 !text-white">
21+
<span>Authorizations Settings</span>
22+
<OpenNewWindow className="text-xs" />
23+
</Link>
24+
</>
25+
);
26+
27+
export const useAllowance = () => {
28+
const { address } = useWallet();
29+
const [defaultFeeGranter, setDefaultFeeGranter] = useLocalStorage<string | undefined>("default-fee-granter", undefined);
30+
const { data: allFeeGranters, isLoading, isFetched } = useAllowancesGranted(address);
31+
const { enqueueSnackbar } = useSnackbar();
32+
33+
const actualAddresses = useMemo(() => {
34+
if (!address || !allFeeGranters) {
35+
return [];
36+
}
37+
38+
return allFeeGranters.reduce((acc, grant) => {
39+
if (isAfter(parseISO(grant.allowance.expiration), new Date())) {
40+
acc.push(grant.granter);
41+
}
42+
43+
return acc;
44+
}, [] as string[]);
45+
}, [allFeeGranters, address]);
46+
47+
useWhen(
48+
isFetched && address,
49+
() => {
50+
const persistedAddresses = persisted[address] || [];
51+
const added = difference(actualAddresses, persistedAddresses);
52+
const removed = difference(persistedAddresses, actualAddresses);
53+
54+
if (added.length || removed.length) {
55+
persisted[address] = actualAddresses;
56+
localStorage.setItem(`fee-granters`, JSON.stringify(persisted));
57+
}
58+
59+
if (added.length) {
60+
enqueueSnackbar(<Snackbar iconVariant="info" title="New fee allowance granted" subTitle={<AllowanceNotificationMessage />} />, {
61+
variant: "info"
62+
});
63+
}
64+
65+
if (removed.length) {
66+
enqueueSnackbar(<Snackbar iconVariant="warning" title="Some fee allowance is revoked or expired" subTitle={<AllowanceNotificationMessage />} />, {
67+
variant: "warning"
68+
});
69+
}
70+
71+
if (defaultFeeGranter && removed.includes(defaultFeeGranter)) {
72+
setDefaultFeeGranter(undefined);
73+
}
74+
},
75+
[actualAddresses, persisted]
76+
);
77+
78+
return useMemo(
79+
() => ({
80+
fee: {
81+
all: allFeeGranters,
82+
default: defaultFeeGranter,
83+
setDefault: setDefaultFeeGranter,
84+
isLoading
85+
}
86+
}),
87+
[defaultFeeGranter, setDefaultFeeGranter, allFeeGranters, isLoading]
88+
);
89+
};

apps/deploy-web/src/hooks/useWhen.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useEffect } from "react";
2+
3+
export function useWhen<T>(condition: T, run: () => void, deps: unknown[] = []): void {
4+
return useEffect(() => {
5+
if (condition) {
6+
run();
7+
}
8+
// eslint-disable-next-line react-hooks/exhaustive-deps
9+
}, [condition, ...deps]);
10+
}

apps/deploy-web/src/pages/_app.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Router from "next/router";
99
import { ThemeProvider } from "next-themes";
1010
import NProgress from "nprogress"; //nprogress module
1111

12+
import { AllowanceWatcher } from "@src/components/authorizations/AllowanceWatcher";
1213
import GoogleAnalytics from "@src/components/layout/CustomGoogleAnalytics";
1314
import { CustomIntlProvider } from "@src/components/layout/CustomIntlProvider";
1415
import { PageHead } from "@src/components/layout/PageHead";
@@ -71,6 +72,7 @@ const App: React.FunctionComponent<Props> = props => {
7172
<BackgroundTaskProvider>
7273
<TemplatesProvider>
7374
<LocalNoteProvider>
75+
<AllowanceWatcher />
7476
<GoogleAnalytics />
7577
<Component {...pageProps} />
7678
</LocalNoteProvider>

0 commit comments

Comments
 (0)