diff --git a/frontend/public/desmos-logo.png b/frontend/public/desmos-logo.png new file mode 100644 index 000000000..da3973652 Binary files /dev/null and b/frontend/public/desmos-logo.png differ diff --git a/frontend/public/no-authz-grants-illustration.png b/frontend/public/no-authz-grants-illustration.png new file mode 100644 index 000000000..43809bf6e Binary files /dev/null and b/frontend/public/no-authz-grants-illustration.png differ diff --git a/frontend/public/revoke-image.png b/frontend/public/revoke-image.png new file mode 100644 index 000000000..79c5de177 Binary files /dev/null and b/frontend/public/revoke-image.png differ diff --git a/frontend/src/app/(routes)/authz/AuthzPage.tsx b/frontend/src/app/(routes)/authz/AuthzPage.tsx index b86d11d7b..ae7d1637e 100644 --- a/frontend/src/app/(routes)/authz/AuthzPage.tsx +++ b/frontend/src/app/(routes)/authz/AuthzPage.tsx @@ -4,12 +4,17 @@ import DialogCreateAuthzGrant from './components/DialogCreateAuthzGrant'; const AuthzPage = ({ chainIDs }: { chainIDs: string[] }) => { const [isGrantsToMe, setIsGrantsToMe] = useState(true); + const [dialogGrantOpen, setDialogGrantOpen] = useState(false); - const hanldeDialogGrantClose = () => { + const handleDialogGrantClose = () => { setDialogGrantOpen(false); }; +<<<<<<< HEAD console.log(chainIDs); // TODO: dispatch and fetch the grants from authz state +======= + +>>>>>>> cc4fb21 (feat: Implement authz overview and revoke (#1093)) return (
@@ -47,14 +52,25 @@ const AuthzPage = ({ chainIDs }: { chainIDs: string[] }) => {
+<<<<<<< HEAD {/* TODO: Create UI to display grants and render data */} {isGrantsToMe ? 'Grants to me' : 'Grants by me'} +======= + {isGrantsToMe ? ( + + ) : ( + setDialogGrantOpen(true)} + /> + )} +>>>>>>> cc4fb21 (feat: Implement authz overview and revoke (#1093))
); diff --git a/frontend/src/app/(routes)/authz/[...chainNames]/page.tsx b/frontend/src/app/(routes)/authz/[...chainNames]/page.tsx index 292b168f7..65603c522 100644 --- a/frontend/src/app/(routes)/authz/[...chainNames]/page.tsx +++ b/frontend/src/app/(routes)/authz/[...chainNames]/page.tsx @@ -5,10 +5,12 @@ import { RootState } from '@/store/store'; import { useParams } from 'next/navigation'; import React from 'react'; import AuthzPage from '../AuthzPage'; -import '../authz.css' +import '../authz.css'; + const Authz = () => { const params = useParams(); + const paramChains = params.chainNames; const chainNames = typeof paramChains === 'string' ? [paramChains] : paramChains; @@ -31,6 +33,7 @@ const Authz = () => { - Chain Not found - )} + ); }; diff --git a/frontend/src/app/(routes)/authz/authz.css b/frontend/src/app/(routes)/authz/authz.css index e7a72ae30..717a9959f 100644 --- a/frontend/src/app/(routes)/authz/authz.css +++ b/frontend/src/app/(routes)/authz/authz.css @@ -11,3 +11,50 @@ .grants-type-btn-selected { @apply primary-gradient; } +<<<<<<< HEAD +======= +.authz-card { + @apply flex flex-col items-start gap-4 backdrop-blur-[2px] p-6 rounded-2xl; + background: #0e0b26; + +} +.grant-address { + @apply flex items-center gap-2 opacity-80 p-2 rounded-lg; + background: rgba(255, 255, 255, 0.1); +} +.authz-card-grid { + @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6; +} +.authz-permission-card { + @apply flex flex-col items-start gap-6 backdrop-blur-[2px] p-6 rounded-2xl; + background: rgba(255, 255, 255, 0.05); +} +.authz-small-text { + @apply text-[rgba(255,255,255,0.50)] text-sm font-normal leading-[normal] +} + + +.divider-line { + @apply h-[1px] w-full my-6 bg-[#ffffff35] opacity-50; +} + +.error-box { + @apply flex justify-end mt-2 h-[26px]; +} + +.error-chip { + @apply text-[12px] rounded-lg bg-[#ff00005b] text-white text-center leading-normal max-w-fit py-1 px-2 truncate; +} + +.msg-item { + @apply flex-center-center cursor-pointer rounded-2xl px-6 py-4 text-[14px] bg-[#FFFFFF0D]; +} + +.grant-authz-form { + @apply rounded-2xl bg-[#FFFFFF0D] p-6; +} + +.moniker-name-chip { + @apply rounded-2xl font-light text-[14px] bg-[#FFFFFF0D] px-3 py-2 max-w-[120px] truncate; +} +>>>>>>> cc4fb21 (feat: Implement authz overview and revoke (#1093)) diff --git a/frontend/src/app/(routes)/authz/components/AuthzCard.tsx b/frontend/src/app/(routes)/authz/components/AuthzCard.tsx new file mode 100644 index 000000000..b8244062f --- /dev/null +++ b/frontend/src/app/(routes)/authz/components/AuthzCard.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { AuthorizationInfo } from './DialogAllPermissions'; +import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; +import { RootState } from '@/store/store'; +import { + getMsgNameFromAuthz, + getTypeURLFromAuthorization, +} from '@/utils/authorizations'; + +import { copyToClipboard } from '@/utils/copyToClipboard'; +import { setError } from '@/store/features/common/commonSlice'; +import DialogRevoke from './DialogRevoke'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import { resetTxStatus } from '@/store/features/authz/authzSlice'; + +const AuthzCard = ({ + chainID, + address, + grants, + showCloseIcon = true, + grantee, + granter, + isGrantsByMe = false, +}: { + chainID: string; + address: string; + grants: Authorization[]; + showCloseIcon?: boolean; + grantee: string; + granter: string; + isGrantsByMe?: boolean; +}) => { + const networkLogo = useAppSelector( + (state: RootState) => state.wallet.networks[chainID]?.network.logos.menu + ); + + const nameToChainIDs = useAppSelector( + (state: RootState) => state.wallet.nameToChainIDs + ); + + const [dialogAllPermissionsOpen, setDialogAllPermissionsOpen] = + useState(false); + const handleDialogAllPermissionsClose = () => { + setDialogAllPermissionsOpen(false); + }; + + const getChainName = (chainID: string) => { + let chain: string = ''; + Object.keys(nameToChainIDs).forEach((chainName) => { + if (nameToChainIDs[chainName] === chainID) chain = chainName; + }); + return chain; + }; + // const revoke = useAppSelector((state) => state.authz.txAuthzRes); + const dispatch = useAppDispatch(); + const { getDenomInfo } = useGetChainInfo(); + + const { decimals } = getDenomInfo(chainID); + const { displayDenom } = getDenomInfo(chainID); + + return ( +
+
+ Network-Logo +

{getChainName(chainID)}

+
+
+ {isGrantsByMe ? 'Grantee' : 'Granter'} +
+
+

{address}

+ { + copyToClipboard(address); + dispatch( + setError({ + type: 'success', + message: 'Copied', + }) + ); + e.preventDefault(); + e.stopPropagation(); + }} + src="/copy.svg" + width={24} + height={24} + alt="copy" + draggable={false} + className="cursor-pointer" + /> +
+
Permissions
+
+ {grants.map((permission, permissionIndex) => ( +
+ {permissionIndex > 2 ? null : ( + + )} +
+ ))} +
+
+ +
+ +
+ ); +}; + +export default AuthzCard; + +const MessageChip = ({ + permission, + granter, + grantee, + chainID, + showCloseIcon, +}: { + permission: Authorization; + granter: string; + grantee: string; + chainID: string; + showCloseIcon: boolean; +}) => { + const [dialogRevokeOpen, setDialogRevokeOpen] = useState(false); + const dispatch = useAppDispatch(); + const handleDialogRevokeClose = () => { + setDialogRevokeOpen(false); + }; + return ( +
+

+ {getMsgNameFromAuthz(permission)} + {showCloseIcon && ( + close-icon { + setDialogRevokeOpen(true); + dispatch(resetTxStatus({ chainID: chainID })); + }} + /> + )} +

+ +
+ ); +}; diff --git a/frontend/src/app/(routes)/authz/components/DialogAllPermissions.tsx b/frontend/src/app/(routes)/authz/components/DialogAllPermissions.tsx new file mode 100644 index 000000000..ed4b35156 --- /dev/null +++ b/frontend/src/app/(routes)/authz/components/DialogAllPermissions.tsx @@ -0,0 +1,340 @@ +import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; +import useGetAuthzRevokeMsgs from '@/custom-hooks/useGetAuthzRevokeMsgs'; +import { txAuthzRevoke } from '@/store/features/authz/authzSlice'; +import { RootState } from '@/store/store'; +import { TxStatus } from '@/types/enums'; +import { + getAllTypeURLsFromAuthorization, + getTypeURLFromAuthorization, + getTypeURLName, +} from '@/utils/authorizations'; +import { dialogBoxPaperPropStyles } from '@/utils/commonStyles'; +import { CLOSE_ICON_PATH } from '@/utils/constants'; +import { getLocalTime } from '@/utils/dataTime'; +import { parseSpendLimit } from '@/utils/denom'; +import { CircularProgress, Dialog, DialogContent } from '@mui/material'; +import Image from 'next/image'; +import React, { useState } from 'react'; + +interface AuthorizationInfoProps { + open: boolean; + onClose: () => void; + authorization: Authorization[]; + displayDenom: string; + decimal: number; + chainID: string; + granter: string; + grantee: string; +} + +interface RenderAuthorizationProps { + authz: Authorization; + displayDenom: string; + decimal: number; + chainID: string; + granter: string; + grantee: string; + permissionIndex: number; + changeMsgIndex: (index: number) => void; + selectedMsgIndex: number; + setRevokeAllMsgs: () => void; +} + +const RenderAuthorization = ({ + authz, + chainID, + changeMsgIndex, + decimal, + displayDenom, + grantee, + granter, + permissionIndex, + selectedMsgIndex, + setRevokeAllMsgs, +}: RenderAuthorizationProps) => { + const { authorization, expiration } = authz; + const stakeAuthzs = { + AUTHORIZATION_TYPE_REDELEGATE: 'Redelegate', + AUTHORIZATION_TYPE_DELEGATE: 'Delegate', + AUTHORIZATION_TYPE_UNDELEGATE: 'Undelegate', + }; + + const dispatch = useAppDispatch(); + const { txRevokeAuthzInputs } = useGetAuthzRevokeMsgs({ + granter, + grantee, + chainID, + typeURLs: [getTypeURLFromAuthorization(authz)], + }); + const txRevoke = (permissionIndex: number) => { + setRevokeAllMsgs(); + changeMsgIndex(permissionIndex); + const { basicChainInfo, denom, feeAmount, feegranter, msgs } = + txRevokeAuthzInputs; + dispatch( + txAuthzRevoke({ + basicChainInfo, + denom, + feeAmount, + feegranter, + msgs, + }) + ); + }; + const loading = useAppSelector( + (state: RootState) => state.authz.chains?.[chainID].tx.status + ); + const isSelected = selectedMsgIndex === permissionIndex; + + switch (authorization['@type']) { + case '/cosmos.bank.v1beta1.SendAuthorization': + return ( +
+
+
+ Send +
+ +
+
+
+
+ Spend Limit +
+
+ {parseSpendLimit(authorization.spend_limit, decimal)} +   + {displayDenom}{' '} +
+
+ +
+
Expiry
+
+ {expiration ? getLocalTime(expiration) : '-'} +
+
+
+
+ ); + case '/cosmos.authz.v1beta1.GenericAuthorization': + return ( +
+
+
+ {getTypeURLName(authorization.msg)} +
+ +
+
+
Expiry
+
+ {expiration ? getLocalTime(expiration) : '-'} +
+
+
+ ); + case '/cosmos.staking.v1beta1.StakeAuthorization': + return ( +
+
+
+ {stakeAuthzs[authorization.authorization_type]} +
+ +
+
+
+
Spend Limit
+ + {authorization.max_tokens !== null && ( +
+ {parseSpendLimit([authorization.max_tokens], decimal)} +   + {displayDenom}{' '} +
+ )} +
+
+
+
Expiry
+
+ {expiration ? getLocalTime(expiration) : '-'} +
+
+
+
+
+ {authorization.allow_list && ( +
+
Allow List:
+
+ {authorization.allow_list?.address.map((addr, index) => ( +
+ {addr.slice(0, 15)} +
+ ))} +
+
+ )} + {authorization.deny_list && ( +
+
Deny List:
+
+ {authorization.deny_list?.address.map((addr, index) => ( +
+ {addr.slice(0, 15)} +
+ ))} +
+
+ )} +
+
+ ); + + default: + return <>Not Supported; + } +}; + +export function AuthorizationInfo(props: AuthorizationInfoProps) { + const { + onClose, + open, + displayDenom, + decimal, + chainID, + authorization, + grantee, + granter, + } = props; + + const dispatch = useAppDispatch(); + const handleClose = () => { + onClose(); + }; + const [selectedMsgIndex, setSelectedMsgIndex] = useState(NaN); + const [revokeAllMsgs, setRevokeAllMsgs] = useState(false); + + const changeMsgIndex = (index: number) => { + setSelectedMsgIndex(index); + }; + + const { txRevokeAuthzInputs } = useGetAuthzRevokeMsgs({ + granter, + grantee, + chainID, + typeURLs: getAllTypeURLsFromAuthorization(authorization), + }); + const { basicChainInfo, denom, feeAmount, feegranter, msgs } = + txRevokeAuthzInputs; + const txRevoke = () => { + setSelectedMsgIndex(NaN); + setRevokeAllMsgs(true); + dispatch( + txAuthzRevoke({ + basicChainInfo, + denom, + feeAmount, + feegranter, + msgs, + }) + ); + }; + + const loading = useAppSelector( + (state: RootState) => state.authz.chains?.[chainID].tx.status + ); + + return ( + + +
+
+ {' '} +
+ close +
+
+ +
+
+
+ All Permissions +
+ +
+
+ {authorization.map((permission, permissionIndex) => ( +
+ setRevokeAllMsgs(false)} + /> +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/(routes)/authz/components/DialogRevoke.tsx b/frontend/src/app/(routes)/authz/components/DialogRevoke.tsx new file mode 100644 index 000000000..76bd926c5 --- /dev/null +++ b/frontend/src/app/(routes)/authz/components/DialogRevoke.tsx @@ -0,0 +1,123 @@ +import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; +import useGetAuthzRevokeMsgs from '@/custom-hooks/useGetAuthzRevokeMsgs'; + +import { txAuthzRevoke } from '@/store/features/authz/authzSlice'; +import { RootState } from '@/store/store'; +import { TxStatus } from '@/types/enums'; +import { dialogBoxPaperPropStyles } from '@/utils/commonStyles'; +import { CLOSE_ICON_PATH } from '@/utils/constants'; +import { CircularProgress, Dialog, DialogContent } from '@mui/material'; + +import Image from 'next/image'; +import React, { useEffect } from 'react'; + +interface DialogRevokeProps { + open: boolean; + onClose: () => void; + chainID: string; + grantee: string; + granter: string; + typeURL: string; +} + +const DialogRevoke: React.FC = (props) => { + const { open, onClose, chainID, grantee, granter, typeURL } = props; + const dispatch = useAppDispatch(); + const { txRevokeAuthzInputs } = useGetAuthzRevokeMsgs({ + granter, + grantee, + chainID, + typeURLs: [typeURL], + }); + const { basicChainInfo, denom, feeAmount, feegranter, msgs } = + txRevokeAuthzInputs; + const txRevoke = () => { + dispatch( + txAuthzRevoke({ + basicChainInfo, + denom, + feeAmount, + feegranter, + msgs, + }) + ); + }; + const loading = useAppSelector( + (state: RootState) => state.authz.chains?.[chainID].tx.status + ); + + useEffect(() => { + if (loading === TxStatus.IDLE) { + onClose(); + } + }, [loading]); + return ( + + +
+
+
+ Close +
+
+
+
+
+ Revoke +
+
+
+ Revoke Permission? +
+
+ Are you sure you want to delete this transaction ? This action + cannot be undone +
+
+ +

+ Cancel +

+
+
+
+
+
+
+
+
+ ); +}; + +export default DialogRevoke; diff --git a/frontend/src/app/(routes)/authz/components/GrantsByMe.tsx b/frontend/src/app/(routes)/authz/components/GrantsByMe.tsx new file mode 100644 index 000000000..44473e408 --- /dev/null +++ b/frontend/src/app/(routes)/authz/components/GrantsByMe.tsx @@ -0,0 +1,65 @@ +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import useAuthzGrants from '@/custom-hooks/useAuthzGrants'; +import AuthzCard from './AuthzCard'; +import { CircularProgress } from '@mui/material'; +import Image from 'next/image'; +import { NO_GRANTS_BY_ME_TEXT } from '@/utils/constants'; + +const GrantsByMe = ({ + chainIDs, + handleGrantDialogOpen, +}: { + chainIDs: string[]; + handleGrantDialogOpen: () => void; +}) => { + const { getGrantsByMe } = useAuthzGrants(); + const addressGrants = getGrantsByMe(chainIDs); + const loading = useAppSelector((state) => state.authz.getGrantsByMeLoading); + + return addressGrants.length ? ( + <> +
+ {addressGrants.map((addressGrant) => ( + <> + {!!addressGrant.grants.length && ( + + )} + + ))} +
+ + ) : !!loading ? ( +
+ +
+ ) : ( +
+ no action proposals +
+ {NO_GRANTS_BY_ME_TEXT} +
+ +
+ ); +}; + +export default GrantsByMe; diff --git a/frontend/src/app/(routes)/authz/components/GrantsToMe.tsx b/frontend/src/app/(routes)/authz/components/GrantsToMe.tsx new file mode 100644 index 000000000..018fc99df --- /dev/null +++ b/frontend/src/app/(routes)/authz/components/GrantsToMe.tsx @@ -0,0 +1,55 @@ +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import useAuthzGrants from '@/custom-hooks/useAuthzGrants'; +import AuthzCard from './AuthzCard'; +import { CircularProgress } from '@mui/material'; +import Image from 'next/image'; +import { NO_GRANTS_TO_ME_TEXT } from '@/utils/constants'; + +const GrantsToMe = ({ chainIDs }: { chainIDs: string[] }) => { + const { getGrantsToMe } = useAuthzGrants(); + const addressGrants = getGrantsToMe(chainIDs); + const loading = useAppSelector((state) => state.authz.getGrantsToMeLoading); + + return addressGrants.length ? ( + <> +
+ {addressGrants.map((addressGrant) => ( + + ))} +
+ + ) : !!loading ? ( +
+ +
+ ) : ( +
+
+
+
+ no action proposals +

+ {NO_GRANTS_TO_ME_TEXT} +

+
+
+
+
+ ); +}; + +export default GrantsToMe; diff --git a/frontend/src/app/(routes)/authz/page.tsx b/frontend/src/app/(routes)/authz/page.tsx index 539eefc6b..15bc09db5 100644 --- a/frontend/src/app/(routes)/authz/page.tsx +++ b/frontend/src/app/(routes)/authz/page.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useAppSelector } from '@/custom-hooks/StateHooks'; import { RootState } from '@/store/store'; import AuthzPage from './AuthzPage'; -import './authz.css' +import './authz.css'; const Authz = () => { const nameToChainIDs = useAppSelector( diff --git a/frontend/src/custom-hooks/useGetAuthzRevokeMsgs.ts b/frontend/src/custom-hooks/useGetAuthzRevokeMsgs.ts new file mode 100644 index 000000000..b45e97c6f --- /dev/null +++ b/frontend/src/custom-hooks/useGetAuthzRevokeMsgs.ts @@ -0,0 +1,38 @@ +import useGetChainInfo from './useGetChainInfo'; +import { AuthzRevokeMsg } from '@/txns/authz'; + +const useGetAuthzRevokeMsgs = ({ + granter, + grantee, + chainID, + typeURLs, +}: { + granter: string; + grantee: string; + chainID: string; + typeURLs: string[]; +}) => { + const { getChainInfo, getDenomInfo } = useGetChainInfo(); + const basicChainInfo = getChainInfo(chainID); + const { decimals, minimalDenom } = getDenomInfo(chainID); + const { feeAmount: avgFeeAmount } = basicChainInfo; + const feeAmount = avgFeeAmount * 10 ** decimals; + + const revokeAuthzMsgs: Msg[] = []; + typeURLs.forEach((typeURL) => { + const msg = AuthzRevokeMsg(granter, grantee, typeURL); + revokeAuthzMsgs.push(msg); + }); + const txRevokeAuthzInputs = { + basicChainInfo: basicChainInfo, + denom: minimalDenom, + feeAmount: feeAmount, + feegranter: '', + msgs: revokeAuthzMsgs, + }; + return { + txRevokeAuthzInputs, + }; +}; + +export default useGetAuthzRevokeMsgs; diff --git a/frontend/src/store/features/authz/authzSlice.ts b/frontend/src/store/features/authz/authzSlice.ts new file mode 100644 index 000000000..9e7906744 --- /dev/null +++ b/frontend/src/store/features/authz/authzSlice.ts @@ -0,0 +1,545 @@ +'use client'; + +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import authzService from './service'; +import { TxStatus } from '../../../types/enums'; +import { cloneDeep } from 'lodash'; +import { getAddressByPrefix } from '@/utils/address'; +import { signAndBroadcast } from '@/utils/signing'; +import { setError, setTxAndHash } from '../common/commonSlice'; +import { NewTransaction } from '@/utils/transaction'; +import { addTransactions } from '../transactionHistory/transactionHistorySlice'; +import { GAS_FEE } from '@/utils/constants'; +import { ERR_UNKNOWN } from '@/utils/errors'; +import { AxiosError } from 'axios'; + +interface ChainAuthz { + grantsToMe: Authorization[]; + grantsByMe: Authorization[]; + getGrantsToMeLoading: { + status: TxStatus; + errMsg: string; + }; + getGrantsByMeLoading: { + status: TxStatus; + errMsg: string; + }; + + /* + this is mapping of address to list of authorizations (chain level) + example : { + "pasg1..." : ["stakeAuthorization...", "sendAuthorization..."] + } + */ + + GrantsToMeAddressMapping: Record; + GrantsByMeAddressMapping: Record; + tx: { + status: TxStatus; + errMsg: string; + }; +} + +interface GetAuthRevokeInputs { + basicChainInfo: BasicChainInfo; + feegranter: string; + denom: string; + msgs: Msg[]; + feeAmount: number; +} +const defaultState: ChainAuthz = { + grantsToMe: [], + grantsByMe: [], + getGrantsByMeLoading: { + status: TxStatus.INIT, + errMsg: '', + }, + getGrantsToMeLoading: { + status: TxStatus.INIT, + errMsg: '', + }, + GrantsByMeAddressMapping: {}, + GrantsToMeAddressMapping: {}, + tx: { + status: TxStatus.INIT, + errMsg: '', + }, +}; + +interface AuthzState { + authzModeEnabled: boolean; + authzAddress: string; + chains: Record; + getGrantsToMeLoading: number; + getGrantsByMeLoading: number; + /* + this is mapping of address to chain id to list of authorizations (inter chain level) + example : { + "cosmos1..." : { + "cosmoshub-4": ["stakeAuthorization...", "sendAuthorization..."] + } + } + */ + AddressToChainAuthz: Record>; + multiChainAuthzGrantTx: { + status: TxStatus; + }; +} + +const initialState: AuthzState = { + authzModeEnabled: false, + authzAddress: '', + chains: {}, + getGrantsByMeLoading: 0, + getGrantsToMeLoading: 0, + AddressToChainAuthz: {}, + multiChainAuthzGrantTx: { + status: TxStatus.INIT, + }, +}; + +export const getGrantsToMe = createAsyncThunk( + 'authz/grantsToMe', + async (data: GetGrantsInputs) => { + const response = await authzService.grantsToMe( + data.baseURL, + data.address, + data.pagination + ); + + return { + data: response.data, + }; + } +); + +export const getGrantsByMe = createAsyncThunk( + 'authz/grantsByMe', + async (data: GetGrantsInputs) => { + const response = await authzService.grantsByMe( + data.baseURL, + data.address, + data.pagination + ); + return { + data: response.data, + }; + } +); + +export const txCreateMultiChainAuthzGrant = createAsyncThunk( + 'authz/create-multichain-grant', + async (data: TxGrantMultiChainAuthzInputs, { rejectWithValue, dispatch }) => { + try { + const promises = data.data.map((chainGrant) => { + return dispatch(txCreateAuthzGrant(chainGrant)); + }); + await Promise.all(promises); + data.data.forEach((chainGrant) => { + dispatch(txCreateAuthzGrant(chainGrant)); + }); + } catch (error) { + if (error instanceof AxiosError) return rejectWithValue(error.response); + } + } +); + +export const txCreateAuthzGrant = createAsyncThunk( + 'authz/create-grant', + async ( + data: TxGrantAuthzInputs, + { rejectWithValue, fulfillWithValue, dispatch } + ) => { + try { + const result = await signAndBroadcast( + data.basicChainInfo.chainID, + data.basicChainInfo.aminoConfig, + data.basicChainInfo.prefix, + data.msgs, + GAS_FEE, + '', + `${data.feeAmount}${data.denom}`, + data.basicChainInfo.rest, + data.feegranter?.length > 0 ? data.feegranter : undefined + ); + + // TODO: Store txn, (This is throwing error because of BigInt in message) + // const tx = NewTransaction( + // result, + // data.msgs, + // data.basicChainInfo.chainID, + // data.basicChainInfo.address + // ); + // dispatch( + // addTransactions({ + // chainID: data.basicChainInfo.chainID, + // address: data.basicChainInfo.cosmosAddress, + // transactions: [tx], + // }) + // ); + + // dispatch( + // setTxAndHash({ + // tx: undefined, + // hash: tx.transactionHash, + // }) + // ); + + if (result?.code === 0) { + dispatch( + getGrantsByMe({ + baseURL: data.basicChainInfo.baseURL, + address: data.basicChainInfo.address, + chainID: data.basicChainInfo.chainID, + }) + ); + return fulfillWithValue({ txHash: result?.transactionHash }); + } else { + return rejectWithValue(result?.rawLog); + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + return rejectWithValue(error?.message || ERR_UNKNOWN); + } + } +); + +export const txAuthzExec = createAsyncThunk( + 'authz/tx-exec', + async ( + data: txAuthzExecInputs, + { rejectWithValue, fulfillWithValue, dispatch } + ) => { + try { + const result = await signAndBroadcast( + data.basicChainInfo.chainID, + data.basicChainInfo.aminoConfig, + data.basicChainInfo.prefix, + data.msgs, + GAS_FEE, + data.metaData, + `${data.basicChainInfo.feeAmount}${data.feeDenom}`, + data.basicChainInfo.rest, + data.feeGranter + ); + if (result?.code === 0) { + const tx = NewTransaction( + result, + data.msgs, + data.basicChainInfo.chainID, + data.basicChainInfo.cosmosAddress + ); + dispatch( + addTransactions({ + transactions: [tx], + chainID: data.basicChainInfo.chainID, + address: data.basicChainInfo.cosmosAddress, + }) + ); + dispatch( + setTxAndHash({ + hash: result?.transactionHash, + tx, + }) + ); + return fulfillWithValue({ txHash: result?.transactionHash }); + } else { + dispatch( + setError({ + type: 'error', + message: result?.rawLog || 'transaction Failed', + }) + ); + return rejectWithValue(result?.rawLog); + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + dispatch( + setError({ + type: 'error', + message: error.message, + }) + ); + return rejectWithValue(error.message); + } + } +); + +export const txAuthzRevoke = createAsyncThunk( + 'authz/tx-revoke', + async ( + data: GetAuthRevokeInputs, + { rejectWithValue, fulfillWithValue, dispatch } + ) => { + try { + const result = await signAndBroadcast( + data.basicChainInfo.chainID, + data.basicChainInfo.aminoConfig, + data.basicChainInfo.prefix, + data.msgs, + GAS_FEE, + '', + `${data.feeAmount}${data.denom}`, + data.basicChainInfo.rest + // data.feegranter?.length > 0 ? data.feegranter : undefined + ); + if (result?.code === 0) { + const tx = NewTransaction( + result, + data.msgs, + data.basicChainInfo.chainID, + data.basicChainInfo.cosmosAddress + ); + dispatch( + addTransactions({ + transactions: [tx], + chainID: data.basicChainInfo.chainID, + address: data.basicChainInfo.cosmosAddress, + }) + ); + dispatch( + setTxAndHash({ + hash: result?.transactionHash, + tx, + }) + ); + dispatch( + setTxAndHash({ + tx: tx, + hash: result?.transactionHash, + }) + ); + dispatch( + getGrantsByMe({ + baseURL: data.basicChainInfo.baseURL, + address: data.basicChainInfo.address, + chainID: data.basicChainInfo.chainID, + }) + ); + return fulfillWithValue({ txHash: result?.transactionHash }); + } else { + dispatch( + setError({ + type: 'error', + message: result?.rawLog || '', + }) + ); + return rejectWithValue(result?.rawLog); + } + } catch (error) { + dispatch( + setError({ + type: 'error', + message: ERR_UNKNOWN, + }) + ); + return rejectWithValue(ERR_UNKNOWN); + } + } +); + +export const authzSlice = createSlice({ + name: 'authz', + initialState, + reducers: { + enableAuthzMode: (state, action: PayloadAction<{ address: string }>) => { + state.authzModeEnabled = true; + state.authzAddress = action.payload.address; + }, + exitAuthzMode: (state) => { + state.authzModeEnabled = false; + state.authzAddress = ''; + }, + resetState: (state) => { + /* eslint-disable @typescript-eslint/no-unused-vars */ + state = cloneDeep(initialState); + }, + resetTxStatus: (state, action: PayloadAction<{ chainID: string }>) => { + const { chainID } = action.payload; + state.chains[chainID].tx = { + errMsg: '', + status: TxStatus.INIT, + }; + }, + }, + extraReducers: (builder) => { + builder + .addCase(getGrantsToMe.pending, (state, action) => { + state.getGrantsToMeLoading++; + const chainID = action.meta.arg.chainID; + if (!state.chains[chainID]) + state.chains[chainID] = cloneDeep(defaultState); + state.chains[chainID].getGrantsToMeLoading.status = TxStatus.PENDING; + state.chains[chainID].grantsToMe = []; + state.chains[chainID].GrantsToMeAddressMapping = {}; + const allAddressToAuthz = state.AddressToChainAuthz; + const addresses = Object.keys(allAddressToAuthz); + addresses.forEach((address) => { + allAddressToAuthz[address][chainID] = []; + }); + + state.AddressToChainAuthz = allAddressToAuthz; + }) + .addCase(getGrantsToMe.fulfilled, (state, action) => { + const chainID = action.meta.arg.chainID; + const allAddressToAuthz = state.AddressToChainAuthz; + const addresses = Object.keys(allAddressToAuthz); + addresses.forEach((address) => { + allAddressToAuthz[address][chainID] = []; + }); + + state.AddressToChainAuthz = allAddressToAuthz; + + state.getGrantsToMeLoading--; + + const grants = action.payload.data.grants; + state.chains[chainID].grantsToMe = grants; + const addressMapping: Record = {}; + const allChainsAddressToGrants = state.AddressToChainAuthz; + + grants.forEach((grant: Authorization) => { + const granter = grant.granter; + const cosmosAddress = getAddressByPrefix(granter, 'cosmos'); + if (!addressMapping[granter]) addressMapping[granter] = []; + if (!allChainsAddressToGrants[cosmosAddress]) + allChainsAddressToGrants[cosmosAddress] = {}; + if (!allChainsAddressToGrants[cosmosAddress][chainID]) + allChainsAddressToGrants[cosmosAddress][chainID] = []; + allChainsAddressToGrants[cosmosAddress][chainID] = [ + ...allChainsAddressToGrants[cosmosAddress][chainID], + grant, + ]; + addressMapping[granter] = [...addressMapping[granter], grant]; + }); + state.AddressToChainAuthz = allChainsAddressToGrants; + state.chains[chainID].GrantsToMeAddressMapping = addressMapping; + state.chains[chainID].getGrantsToMeLoading = { + status: TxStatus.IDLE, + errMsg: '', + }; + }) + .addCase(getGrantsToMe.rejected, (state, action) => { + state.getGrantsToMeLoading--; + const chainID = action.meta.arg.chainID; + + state.chains[chainID].getGrantsToMeLoading = { + status: TxStatus.REJECTED, + errMsg: + action.error.message || + 'An error occurred while fetching authz grants to me', + }; + }); + builder + .addCase(getGrantsByMe.pending, (state, action) => { + state.getGrantsByMeLoading++; + const chainID = action.meta.arg.chainID; + if (!state.chains[chainID]) + state.chains[chainID] = cloneDeep(defaultState); + state.chains[chainID].getGrantsByMeLoading.status = TxStatus.PENDING; + state.chains[chainID].grantsByMe = []; + state.chains[chainID].GrantsByMeAddressMapping = {}; + }) + .addCase(getGrantsByMe.fulfilled, (state, action) => { + state.getGrantsByMeLoading--; + const chainID = action.meta.arg.chainID; + const grants = action.payload.data.grants; + state.chains[chainID].grantsByMe = grants; + const addressMapping: Record = {}; + grants.forEach((grant: Authorization) => { + const granter = grant.grantee; + if (!addressMapping[granter]) addressMapping[granter] = []; + addressMapping[granter] = [...addressMapping[granter], grant]; + }); + state.chains[chainID].GrantsByMeAddressMapping = addressMapping; + state.chains[chainID].getGrantsByMeLoading = { + status: TxStatus.IDLE, + errMsg: '', + }; + }) + .addCase(getGrantsByMe.rejected, (state, action) => { + state.getGrantsByMeLoading--; + const chainID = action.meta.arg.chainID; + + state.chains[chainID].getGrantsByMeLoading = { + status: TxStatus.REJECTED, + errMsg: + action.error.message || + 'An error occurred while fetching authz grants by me', + }; + }); + builder + .addCase(txAuthzExec.pending, (state, action) => { + const chainID = action.meta.arg.basicChainInfo.chainID; + state.chains[chainID].tx.status = TxStatus.PENDING; + state.chains[chainID].tx.errMsg = ''; + }) + .addCase(txAuthzExec.fulfilled, (state, action) => { + const chainID = action.meta.arg.basicChainInfo.chainID; + state.chains[chainID].tx.status = TxStatus.IDLE; + }) + .addCase(txAuthzExec.rejected, (state, action) => { + const chainID = action.meta.arg.basicChainInfo.chainID; + state.chains[chainID].tx.status = TxStatus.REJECTED; + state.chains[chainID].tx.errMsg = action.error.message || 'rejected'; + }); + builder + .addCase(txCreateAuthzGrant.pending, (state, action) => { + const { chainID } = action.meta.arg.basicChainInfo; + state.chains[chainID].tx.status = TxStatus.PENDING; + state.chains[chainID].tx.errMsg = ''; + }) + .addCase(txCreateAuthzGrant.fulfilled, (state, action) => { + const { chainID } = action.meta.arg.basicChainInfo; + const { txHash } = action.payload; + state.chains[chainID].tx.status = TxStatus.IDLE; + state.chains[chainID].tx.errMsg = ''; + action.meta.arg.onTxComplete?.({ + isTxSuccess: true, + txHash: txHash, + }); + }) + .addCase(txCreateAuthzGrant.rejected, (state, action) => { + const { chainID } = action.meta.arg.basicChainInfo; + state.chains[chainID].tx.status = TxStatus.REJECTED; + state.chains[chainID].tx.errMsg = + typeof action.payload === 'string' ? action.payload : ''; + action.meta.arg.onTxComplete?.({ + isTxSuccess: false, + error: + typeof action.payload === 'string' ? action.payload : ERR_UNKNOWN, + }); + }); + + builder + .addCase(txCreateMultiChainAuthzGrant.pending, (state) => { + state.multiChainAuthzGrantTx.status = TxStatus.PENDING; + }) + .addCase(txCreateMultiChainAuthzGrant.fulfilled, (state) => { + state.multiChainAuthzGrantTx.status = TxStatus.IDLE; + }) + .addCase(txCreateMultiChainAuthzGrant.rejected, (state) => { + state.multiChainAuthzGrantTx.status = TxStatus.REJECTED; + }); + + builder + .addCase(txAuthzRevoke.pending, (state, action) => { + const chainID = action.meta.arg.basicChainInfo.chainID; + state.chains[chainID].tx.status = TxStatus.PENDING; + state.chains[chainID].tx.errMsg = ''; + }) + .addCase(txAuthzRevoke.fulfilled, (state, action) => { + const chainID = action.meta.arg.basicChainInfo.chainID; + state.chains[chainID].tx.status = TxStatus.IDLE; + }) + .addCase(txAuthzRevoke.rejected, (state, action) => { + const chainID = action.meta.arg.basicChainInfo.chainID; + state.chains[chainID].tx.status = TxStatus.REJECTED; + state.chains[chainID].tx.errMsg = action.error.message || 'rejected'; + }); + }, +}); + +export const { enableAuthzMode, exitAuthzMode, resetState, resetTxStatus } = + authzSlice.actions; + +export default authzSlice.reducer; diff --git a/frontend/src/txns/authz/index.ts b/frontend/src/txns/authz/index.ts new file mode 100644 index 000000000..fb2a78cfc --- /dev/null +++ b/frontend/src/txns/authz/index.ts @@ -0,0 +1,10 @@ +export { AuthzGenericGrantMsg, AuthzSendGrantMsg } from "./grant"; +export { + AuthzExecDelegateMsg, + AuthzExecReDelegateMsg, + AuthzExecSendMsg, + AuthzExecUnDelegateMsg, + AuthzExecVoteMsg, + AuthzExecWithdrawRewardsMsg, +} from './exec'; +export { AuthzRevokeMsg } from './revoke'; diff --git a/frontend/src/txns/authz/revoke.ts b/frontend/src/txns/authz/revoke.ts new file mode 100644 index 000000000..66d598c71 --- /dev/null +++ b/frontend/src/txns/authz/revoke.ts @@ -0,0 +1,18 @@ +import { MsgRevoke } from 'cosmjs-types/cosmos/authz/v1beta1/tx'; + +const msgAuthzRevokeTypeUrl = '/cosmos.authz.v1beta1.MsgRevoke'; + +export function AuthzRevokeMsg( + granter: string, + grantee: string, + typeURL: string +): Msg { + return { + typeUrl: msgAuthzRevokeTypeUrl, + value: MsgRevoke.fromPartial({ + msgTypeUrl: typeURL, + grantee: grantee, + granter: granter, + }), + }; +} diff --git a/frontend/src/types/authz.d.ts b/frontend/src/types/authz.d.ts new file mode 100644 index 000000000..5a510651c --- /dev/null +++ b/frontend/src/types/authz.d.ts @@ -0,0 +1,105 @@ +interface Authorization { + granter: string; + grantee: string; + expiration: string | null; + authorization: GenericAuthorization | SendAuthorization | StakeAuthorization; +} + +interface GenericAuthorization { + spend_limit: Coin[]; + '@type': '/cosmos.authz.v1beta1.GenericAuthorization'; + msg: string; +} + +interface SendAuthorization { + msg: ReactNode; + '@type': '/cosmos.bank.v1beta1.SendAuthorization'; + spend_limit: Coin[]; + allow_list?: string[]; +} + +interface StakeAuthorization { + msg: ReactNode; + spend_limit: Coin[]; + + '@type': '/cosmos.staking.v1beta1.StakeAuthorization'; + + max_tokens: null | Coin; + allow_list: undefined | AddressList; + deny_list: undefined | AddressList; + authorization_type: AuthzDelegateType | AuthzReDelegateType | AuthzUnBondType; +} + +interface AddressList { + address: string[]; +} + +type AuthzUnBondType = 'AUTHORIZATION_TYPE_UNDELEGATE'; +type AuthzDelegateType = 'AUTHORIZATION_TYPE_DELEGATE'; +type AuthzReDelegateType = 'AUTHORIZATION_TYPE_REDELEGATE'; + +interface GetGrantsInputs { + baseURL: string; + address: string; + pagination?: KeyLimitPagination; + chainID: string; +} + +interface GetGrantsResponse { + grants: Authorization[]; + pagination: Pagination; +} + +interface AddressGrants { + address: string; + chainID: string; + grants: Authorization[]; +} + +interface Grant { + msg: string; + expiration: Date; + spend_limit?: string; + max_tokens?: string; + isDenyList?: boolean; + validators_list?: string[]; +} + +interface TxGrantAuthzInputs { + basicChainInfo: BasicChainInfo; + msgs: Msg[]; + denom: string; + feeAmount: number; + feegranter: string; + onTxComplete?: ({ isTxSuccess, error, txHash }: OnTxnCompleteInputs) => void; +} + +interface TxGrantMultiChainAuthzInputs { + data: TxGrantAuthzInputs[]; +} + +interface MultiChainTx { + ChainID: string; + txInputs: TxGrantAuthzInputs; +} + +interface OnTxnCompleteInputs { + isTxSuccess: boolean; + error?: string; + txHash?: string; +} + +interface ChainStatus { + isTxSuccess?: boolean; + txStatus: string; + error: string; + txHash: string; +} + +interface txAuthzExecInputs { + basicChainInfo: BasicChainInfo; + feeDenom: string; + metaData: string; + msgs: Msg[]; + feeGranter?: string; +} diff --git a/frontend/src/utils/authorizations.ts b/frontend/src/utils/authorizations.ts new file mode 100644 index 000000000..a1d1d868f --- /dev/null +++ b/frontend/src/utils/authorizations.ts @@ -0,0 +1,187 @@ +interface AuthzMenuItem { + txn: string; + typeURL: string; +} + +export function authzMsgTypes(): AuthzMenuItem[] { + return [ + { + txn: 'Send', + typeURL: '/cosmos.bank.v1beta1.MsgSend', + }, + { + txn: 'Grant Authz', + typeURL: '/cosmos.authz.v1beta1.MsgGrant', + }, + { + txn: 'Revoke Authz', + typeURL: '/cosmos.authz.v1beta1.MsgRevoke', + }, + { + txn: 'Grant Feegrant', + typeURL: '/cosmos.feegrant.v1beta1.MsgGrantAllowance', + }, + { + txn: 'Revoke Feegrant', + typeURL: '/cosmos.feegrant.v1beta1.MsgRevokeAllowance', + }, + { + txn: 'Submit Proposal', + typeURL: '/cosmos.gov.v1beta1.MsgSubmitProposal', + }, + { + txn: 'Vote', + typeURL: '/cosmos.gov.v1beta1.MsgVote', + }, + { + txn: 'Deposit', + typeURL: '/cosmos.gov.v1beta1.MsgDeposit', + }, + { + txn: 'Withdraw Rewards', + typeURL: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', + }, + { + txn: 'Redelegate', + typeURL: '/cosmos.staking.v1beta1.MsgBeginRedelegate', + }, + { + txn: 'Delegate', + typeURL: '/cosmos.staking.v1beta1.MsgDelegate', + }, + { + txn: 'Undelegate', + typeURL: '/cosmos.staking.v1beta1.MsgUndelegate', + }, + { + txn: 'Withdraw Commission', + typeURL: '/cosmos.distribution.v1beta1.MsgWithdrawValidatorCommission', + }, + { + txn: 'Unjail', + typeURL: '/cosmos.slashing.v1beta1.MsgUnjail', + }, + ]; +} + +export const MAP_TXN_MSG_TYPES: Record = { + send: '/cosmos.bank.v1beta1.MsgSend', + grant_authz: '/cosmos.authz.v1beta1.MsgGrant', + revoke_authz: '/cosmos.authz.v1beta1.MsgRevoke', + grant_feegrant: '/cosmos.feegrant.v1beta1.MsgGrantAllowance', + revoke_feegrant: '/cosmos.feegrant.v1beta1.MsgRevokeAllowance', + submit_proposal: '/cosmos.gov.v1beta1.MsgSubmitProposal', + vote: '/cosmos.gov.v1beta1.MsgVote', + deposit: '/cosmos.gov.v1beta1.MsgDeposit', + withdraw_rewards: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', + redelegate: '/cosmos.staking.v1beta1.MsgBeginRedelegate', + delegate: '/cosmos.staking.v1beta1.MsgDelegate', + undelegate: '/cosmos.staking.v1beta1.MsgUndelegate', + withdraw_commission: + '/cosmos.distribution.v1beta1.MsgWithdrawValidatorCommission', + unjail: '/cosmos.slashing.v1beta1.MsgUnjail', +}; + +export const grantAuthzFormDefaultValues = () => { + const date = new Date(); + const expiration = new Date(date.setTime(date.getTime() + 365 * 86400000)); + return { + grant_authz: { expiration: expiration }, + revoke_authz: { expiration: expiration }, + grant_feegrant: { expiration: expiration }, + revoke_feegrant: { expiration: expiration }, + submit_proposal: { expiration: expiration }, + vote: { expiration: expiration }, + deposit: { expiration: expiration }, + withdraw_rewards: { expiration: expiration }, + withdraw_commission: { expiration: expiration }, + unjail: { expiration: expiration }, + send: { expiration: expiration, spend_limit: '' }, + delegate: { expiration: expiration, max_tokens: '' }, + undelegate: { expiration: expiration, max_tokens: '' }, + redelegate: { expiration: expiration, max_tokens: '' }, + }; +}; +export function getTypeURLName(url: string) { + if (!url) { + return '-'; + } + const temp = url.split('.'); + if (temp?.length > 0) { + const msg = temp[temp?.length - 1]; + return msg.slice(3, msg.length); + } + return '-'; +} + +function getStakeAuthzType(type: string): string { + switch (type) { + case 'AUTHORIZATION_TYPE_DELEGATE': + return '/cosmos.staking.v1beta1.MsgDelegate'; + case 'AUTHORIZATION_TYPE_UNDELEGATE': + return '/cosmos.staking.v1beta1.MsgUndelegate'; + case 'AUTHORIZATION_TYPE_REDELEGATE': + return '/cosmos.staking.v1beta1.MsgBeginRedelegate'; + default: + throw new Error('unsupported stake authorization type'); + } +} + +export function getMsgNameFromAuthz(authorization: Authorization): string { + switch (authorization.authorization['@type']) { + case '/cosmos.bank.v1beta1.SendAuthorization': + return 'Send'; + case '/cosmos.authz.v1beta1.GenericAuthorization': + return getTypeURLName(authorization.authorization.msg); + case '/cosmos.staking.v1beta1.StakeAuthorization': + const temp = getStakeAuthzType( + authorization?.authorization.authorization_type + ).split('.'); + if (temp.length === 0) { + return 'Unknown'; + } + return temp[temp.length - 1]; + default: + return 'Unknown'; + } +} + +export function getTypeURLFromAuthorization( + authorization: Authorization +): string { + switch (authorization.authorization['@type']) { + case '/cosmos.bank.v1beta1.SendAuthorization': + return '/cosmos.bank.v1beta1.MsgSend'; + case '/cosmos.authz.v1beta1.GenericAuthorization': + return authorization.authorization.msg; + case '/cosmos.staking.v1beta1.StakeAuthorization': + return getStakeAuthzType(authorization?.authorization.authorization_type); + default: + throw new Error('unsupported authorization'); + } +} + +export const getAllTypeURLsFromAuthorization = ( + authorizations: Authorization[] +): string[] => { + const typeURLs: string[] = []; + authorizations.forEach((authorization) => { + typeURLs.push(getTypeURLFromAuthorization(authorization)); + }); + return typeURLs; +}; + +export const GENRIC_GRANTS = [ + 'grant_authz', + 'revoke_authz', + 'grant_feegrant', + 'revoke_feegrant', + 'submit_proposal', + 'vote', + 'deposit', + 'withdraw_rewards', + 'withdraw_commission', + 'unjail', +]; + +export const STAKE_GRANTS = ['delegate', 'undelegate', 'redelegate']; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index dc348b2e7..9416f578a 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -161,3 +161,5 @@ export const TWITTER_ICON = '/twitter-icon.png'; export const TWITTER_LINK = 'https://twitter.com/vitwit_'; export const MIN_SALT_VALUE = 99999; export const MAX_SALT_VALUE = 99999999; +export const NO_GRANTS_BY_ME_TEXT = "You haven't granted any permission yet"; +export const NO_GRANTS_TO_ME_TEXT = "You don't have any grants";