From 37756aeaf79910b1546f4abdb684c8bedbac8598 Mon Sep 17 00:00:00 2001 From: Yaroslav Grishajev Date: Fri, 22 Nov 2024 16:46:36 +0100 Subject: [PATCH] feat(deployment): implement ato top up setting closes #412 --- .../authorizations/Authorizations.tsx | 15 +- .../AutoTopUpSetting/AutoTopUpSetting.tsx | 216 ++++++++++++++++++ .../AutoTopUpSettingContainer.tsx | 75 ++++++ .../components/settings/SettingsContainer.tsx | 6 + .../src/components/settings/SettingsForm.tsx | 1 + .../src/hooks/useAllowanceService.tsx | 9 + .../src/hooks/useAutoTopUpLimits.tsx | 91 ++++++++ .../src/hooks/useAutoTopUpService.ts | 12 + .../src/hooks/useDoubleTransactionAlert.tsx | 27 +++ .../queries/useExactDeploymentGrantsQuery.ts | 8 + .../src/queries/useExactFeeAllowanceQuery.ts | 8 + apps/deploy-web/src/queries/useGrantsQuery.ts | 1 - .../auto-top-up-message.service.ts | 168 ++++++++++++++ .../src/allowance/allowance-http.service.ts | 39 +++- packages/ui/components/input.tsx | 5 +- packages/ui/components/switch.tsx | 2 +- 16 files changed, 669 insertions(+), 14 deletions(-) create mode 100644 apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSetting.tsx create mode 100644 apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSettingContainer.tsx create mode 100644 apps/deploy-web/src/hooks/useAllowanceService.tsx create mode 100644 apps/deploy-web/src/hooks/useAutoTopUpLimits.tsx create mode 100644 apps/deploy-web/src/hooks/useAutoTopUpService.ts create mode 100644 apps/deploy-web/src/hooks/useDoubleTransactionAlert.tsx create mode 100644 apps/deploy-web/src/queries/useExactDeploymentGrantsQuery.ts create mode 100644 apps/deploy-web/src/queries/useExactFeeAllowanceQuery.ts create mode 100644 apps/deploy-web/src/services/auto-top-up-message/auto-top-up-message.service.ts diff --git a/apps/deploy-web/src/components/authorizations/Authorizations.tsx b/apps/deploy-web/src/components/authorizations/Authorizations.tsx index 3fcaf1f35..c4f9a161e 100644 --- a/apps/deploy-web/src/components/authorizations/Authorizations.tsx +++ b/apps/deploy-web/src/components/authorizations/Authorizations.tsx @@ -5,6 +5,7 @@ import { Bank } from "iconoir-react"; import { NextSeo } from "next-seo"; import { Fieldset } from "@src/components/shared/Fieldset"; +import { browserEnvConfig } from "@src/config/browser-env.config"; import { useWallet } from "@src/context/WalletProvider"; import { useAllowance } from "@src/hooks/useAllowance"; import { useAllowancesIssued, useGranteeGrants, useGranterGrants } from "@src/queries/useGrantsQuery"; @@ -26,6 +27,14 @@ type RefreshingType = "granterGrants" | "granteeGrants" | "allowancesIssued" | " const defaultRefetchInterval = 30 * 1000; const refreshingInterval = 1000; +const MASTER_WALLETS = new Set([ + browserEnvConfig.NEXT_PUBLIC_USDC_TOP_UP_MASTER_WALLET_ADDRESS, + browserEnvConfig.NEXT_PUBLIC_UAKT_TOP_UP_MASTER_WALLET_ADDRESS +]); + +const selectNonMaster = (records: Pick[] | Pick[]) => + records.filter(({ grantee }) => !MASTER_WALLETS.has(grantee)); + export const Authorizations: React.FunctionComponent = () => { const { address, signAndBroadcastTx, isManaged } = useWallet(); const { @@ -41,13 +50,15 @@ export const Authorizations: React.FunctionComponent = () => { const [selectedGrants, setSelectedGrants] = useState([]); const [selectedAllowances, setSelectedAllowances] = useState([]); const { data: granterGrants, isLoading: isLoadingGranterGrants } = useGranterGrants(address, { - refetchInterval: isRefreshing === "granterGrants" ? refreshingInterval : defaultRefetchInterval + refetchInterval: isRefreshing === "granterGrants" ? refreshingInterval : defaultRefetchInterval, + select: selectNonMaster }); const { data: granteeGrants, isLoading: isLoadingGranteeGrants } = useGranteeGrants(address, { refetchInterval: isRefreshing === "granteeGrants" ? refreshingInterval : defaultRefetchInterval }); const { data: allowancesIssued, isLoading: isLoadingAllowancesIssued } = useAllowancesIssued(address, { - refetchInterval: isRefreshing === "allowancesIssued" ? refreshingInterval : defaultRefetchInterval + refetchInterval: isRefreshing === "allowancesIssued" ? refreshingInterval : defaultRefetchInterval, + select: selectNonMaster }); useEffect(() => { diff --git a/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSetting.tsx b/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSetting.tsx new file mode 100644 index 000000000..7d5f2fafa --- /dev/null +++ b/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSetting.tsx @@ -0,0 +1,216 @@ +import React, { FC, useCallback, useEffect, useMemo } from "react"; +import { Controller, SubmitHandler, useForm } from "react-hook-form"; +import { Button, Form, FormField, FormInput } from "@akashnetwork/ui/components"; +import { zodResolver } from "@hookform/resolvers/zod"; +import addYears from "date-fns/addYears"; +import format from "date-fns/format"; +import { z } from "zod"; + +import { aktToUakt, uaktToAKT } from "@src/utils/priceUtils"; + +const positiveNumberSchema = z.coerce.number().min(0, { + message: "Amount must be greater or equal to 0." +}); + +const formSchema = z + .object({ + uaktFeeLimit: positiveNumberSchema, + usdcFeeLimit: positiveNumberSchema, + uaktDeploymentLimit: positiveNumberSchema, + usdcDeploymentLimit: positiveNumberSchema, + expiration: z.string().min(1, "Expiration is required.") + }) + .refine( + data => { + if (data.usdcDeploymentLimit > 0) { + return data.usdcFeeLimit > 0; + } + return true; + }, + { + message: "Must be greater than 0 if `USDC Deployments Limit` is greater than 0", + path: ["usdcFeeLimit"] + } + ) + .refine( + data => { + if (data.usdcFeeLimit > 0) { + return data.usdcDeploymentLimit > 0; + } + return true; + }, + { + message: "Must be greater than 0 if `USDC Fees Limit` is greater than 0", + path: ["usdcDeploymentLimit"] + } + ) + .refine( + data => { + if (data.uaktDeploymentLimit > 0) { + return data.uaktFeeLimit > 0; + } + return true; + }, + { + message: "Must be greater than 0 if `AKT Deployments Limit` is greater than 0", + path: ["uaktFeeLimit"] + } + ) + .refine( + data => { + if (data.uaktFeeLimit > 0) { + return data.uaktDeploymentLimit > 0; + } + return true; + }, + { + message: "Must be greater than 0 if `AKT Fees Limit` is greater than 0", + path: ["uaktDeploymentLimit"] + } + ); + +type FormValues = z.infer; + +type LimitFields = keyof Omit; + +type AutoTopUpSubmitHandler = (action: "revoke-all" | "update", next: FormValues) => Promise; + +export interface AutoTopUpSettingProps extends Partial> { + onSubmit: AutoTopUpSubmitHandler; + expiration?: Date; +} + +const fields: LimitFields[] = ["uaktFeeLimit", "usdcFeeLimit", "uaktDeploymentLimit", "usdcDeploymentLimit"]; + +export const AutoTopUpSetting: FC = ({ onSubmit, expiration, ...props }) => { + const hasAny = useMemo(() => fields.some(field => props[field]), [props]); + + const defaultLimitValues = useMemo(() => { + return fields.reduce( + (acc, field) => { + acc[field] = uaktToAKT(props[field] || 0); + return acc; + }, + {} as Record + ); + }, [props]); + + const form = useForm>({ + defaultValues: { + ...defaultLimitValues, + expiration: format(expiration || addYears(new Date(), 1), "yyyy-MM-dd'T'HH:mm") + }, + resolver: zodResolver(formSchema) + }); + const { handleSubmit, control, setValue, reset } = form; + + useEffect(() => { + setValue("uaktFeeLimit", uaktToAKT(props.uaktFeeLimit || 0)); + }, [props.uaktFeeLimit]); + + useEffect(() => { + setValue("usdcFeeLimit", uaktToAKT(props.usdcFeeLimit || 0)); + }, [props.usdcFeeLimit]); + + useEffect(() => { + setValue("uaktDeploymentLimit", uaktToAKT(props.uaktDeploymentLimit || 0)); + }, [props.uaktDeploymentLimit]); + + useEffect(() => { + setValue("usdcDeploymentLimit", uaktToAKT(props.usdcDeploymentLimit || 0)); + }, [props.usdcDeploymentLimit]); + + useEffect(() => { + if (expiration) { + setValue("expiration", format(expiration || addYears(new Date(), 1), "yyyy-MM-dd'T'HH:mm")); + } + }, [expiration]); + + const execSubmitterRoleAction: SubmitHandler = useCallback( + async (next: FormValues, event: React.BaseSyntheticEvent) => { + const role = event.nativeEvent.submitter?.getAttribute("data-role"); + await onSubmit(role as "revoke-all" | "update", convertToUakt(next)); + reset(next); + }, + [onSubmit, reset] + ); + + return ( +
+
+ +
+
+ { + return ; + }} + /> +
+ +
+ { + return ; + }} + /> +
+
+ +
+
+ { + return ; + }} + /> +
+ +
+ { + return ; + }} + /> +
+
+ +
+ { + return ; + }} + /> +
+ + + + {hasAny && ( + + )} +
+ +
+ ); +}; + +function convertToUakt({ ...values }: FormValues) { + return fields.reduce((acc, field) => { + acc[field] = aktToUakt(values[field]); + return acc; + }, values); +} diff --git a/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSettingContainer.tsx b/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSettingContainer.tsx new file mode 100644 index 000000000..637c013d5 --- /dev/null +++ b/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSettingContainer.tsx @@ -0,0 +1,75 @@ +import React, { FC, useCallback, useEffect } from "react"; + +import { AutoTopUpSetting, AutoTopUpSettingProps } from "@src/components/settings/AutoTopUpSetting/AutoTopUpSetting"; +import { useWallet } from "@src/context/WalletProvider"; +import { useAutoTopUpLimits } from "@src/hooks/useAutoTopUpLimits"; +import { useAutoTopUpService } from "@src/hooks/useAutoTopUpService"; +import { useDoubleTransactionAlert } from "@src/hooks/useDoubleTransactionAlert"; + +export const AutoTopUpSettingContainer: FC = () => { + const { address, signAndBroadcastTx } = useWallet(); + const { fetch, uaktFeeLimit, usdcFeeLimit, uaktDeploymentLimit, usdcDeploymentLimit, expiration } = useAutoTopUpLimits(); + const autoTopUpMessageService = useAutoTopUpService(); + const doubleTransactionAlert = useDoubleTransactionAlert(); + + useEffect(() => { + fetch(); + }, []); + + const updateAllowancesAndGrants: AutoTopUpSettingProps["onSubmit"] = useCallback( + async (action, next) => { + const prev = { + uaktFeeLimit, + usdcFeeLimit, + uaktDeploymentLimit, + usdcDeploymentLimit, + expiration + }; + + const { revoke, grant } = autoTopUpMessageService.collectMessages({ + granter: address, + prev, + next: action === "revoke-all" ? undefined : { ...next, expiration: new Date(next.expiration) } + }); + + const closeAlert = revoke.length && grant.length && doubleTransactionAlert.show(); + + if (revoke.length) { + await signAndBroadcastTx(revoke); + } + + if (grant.length) { + await signAndBroadcastTx(grant); + } + + if (closeAlert) { + closeAlert(); + } + + await fetch(); + }, + [ + address, + autoTopUpMessageService, + doubleTransactionAlert, + expiration, + fetch, + signAndBroadcastTx, + uaktDeploymentLimit, + uaktFeeLimit, + usdcDeploymentLimit, + usdcFeeLimit + ] + ); + + return ( + + ); +}; diff --git a/apps/deploy-web/src/components/settings/SettingsContainer.tsx b/apps/deploy-web/src/components/settings/SettingsContainer.tsx index 560cdc990..d6303e513 100644 --- a/apps/deploy-web/src/components/settings/SettingsContainer.tsx +++ b/apps/deploy-web/src/components/settings/SettingsContainer.tsx @@ -5,6 +5,8 @@ import { Edit } from "iconoir-react"; import { useRouter } from "next/navigation"; import { NextSeo } from "next-seo"; +import { AutoTopUpSetting } from "@src/components/settings/AutoTopUpSetting/AutoTopUpSetting"; +import { AutoTopUpSettingContainer } from "@src/components/settings/AutoTopUpSetting/AutoTopUpSettingContainer"; import { LocalDataManager } from "@src/components/settings/LocalDataManager"; import { Fieldset } from "@src/components/shared/Fieldset"; import { LabelValue } from "@src/components/shared/LabelValue"; @@ -58,6 +60,10 @@ export const SettingsContainer: React.FunctionComponent = () => { + +
+ +
diff --git a/apps/deploy-web/src/components/settings/SettingsForm.tsx b/apps/deploy-web/src/components/settings/SettingsForm.tsx index b439e472d..f40077c32 100644 --- a/apps/deploy-web/src/components/settings/SettingsForm.tsx +++ b/apps/deploy-web/src/components/settings/SettingsForm.tsx @@ -95,6 +95,7 @@ export const SettingsForm: React.FunctionComponent = () => { name="apiEndpoint" defaultValue={settings.apiEndpoint} render={({ field }) => { + console.log("DEBUG field", field); return ; }} /> diff --git a/apps/deploy-web/src/hooks/useAllowanceService.tsx b/apps/deploy-web/src/hooks/useAllowanceService.tsx new file mode 100644 index 000000000..6ebf7d593 --- /dev/null +++ b/apps/deploy-web/src/hooks/useAllowanceService.tsx @@ -0,0 +1,9 @@ +import { useMemo } from "react"; +import { AllowanceHttpService } from "@akashnetwork/http-sdk"; + +import { useSettings } from "@src/context/SettingsProvider"; + +export const useAllowanceService = () => { + const { settings } = useSettings(); + return useMemo(() => new AllowanceHttpService({ baseURL: settings.apiEndpoint }), [settings.apiEndpoint]); +}; diff --git a/apps/deploy-web/src/hooks/useAutoTopUpLimits.tsx b/apps/deploy-web/src/hooks/useAutoTopUpLimits.tsx new file mode 100644 index 000000000..9bfa16d6f --- /dev/null +++ b/apps/deploy-web/src/hooks/useAutoTopUpLimits.tsx @@ -0,0 +1,91 @@ +import { useCallback, useMemo } from "react"; +import { ExactDeploymentAllowance, FeeAllowance } from "@akashnetwork/http-sdk"; +import { isFuture } from "date-fns"; +import invokeMap from "lodash/invokeMap"; + +import { browserEnvConfig } from "@src/config/browser-env.config"; +import { useWallet } from "@src/context/WalletProvider"; +import { useExactDeploymentGrantsQuery } from "@src/queries/useExactDeploymentGrantsQuery"; +import { useExactFeeAllowanceQuery } from "@src/queries/useExactFeeAllowanceQuery"; + +export const useAutoTopUpLimits = () => { + const { address } = useWallet(); + const uaktFeeAllowance = useExactFeeAllowanceQuery(address, browserEnvConfig.NEXT_PUBLIC_UAKT_TOP_UP_MASTER_WALLET_ADDRESS, { enabled: false }); + const uaktDeploymentGrant = useExactDeploymentGrantsQuery(address, browserEnvConfig.NEXT_PUBLIC_UAKT_TOP_UP_MASTER_WALLET_ADDRESS, { enabled: false }); + const usdcFeeAllowance = useExactFeeAllowanceQuery(address, browserEnvConfig.NEXT_PUBLIC_USDC_TOP_UP_MASTER_WALLET_ADDRESS, { enabled: false }); + const usdcDeploymentGrant = useExactDeploymentGrantsQuery(address, browserEnvConfig.NEXT_PUBLIC_USDC_TOP_UP_MASTER_WALLET_ADDRESS, { enabled: false }); + const uaktFeeLimit = useMemo(() => extractFeeLimit(uaktFeeAllowance.data), [uaktFeeAllowance.data]); + const usdcFeeLimit = useMemo(() => extractFeeLimit(usdcFeeAllowance.data), [usdcFeeAllowance.data]); + const uaktDeploymentLimit = useMemo(() => extractDeploymentLimit(uaktDeploymentGrant.data), [uaktDeploymentGrant.data]); + const usdcDeploymentLimit = useMemo(() => extractDeploymentLimit(usdcDeploymentGrant.data), [usdcDeploymentGrant.data]); + + const earliestExpiration = useMemo(() => { + const expirations = [ + uaktFeeAllowance.data?.allowance.expiration, + uaktDeploymentGrant.data?.expiration, + usdcFeeAllowance.data?.allowance.expiration, + usdcDeploymentGrant.data?.expiration + ] + .filter(Boolean) + .map(expiration => new Date(expiration!)); + + if (!expirations.length) { + return undefined; + } + + return expirations.reduce((acc, date) => { + if (date < acc) { + return date; + } + + return acc; + }); + }, [ + uaktDeploymentGrant.data?.expiration, + uaktFeeAllowance.data?.allowance.expiration, + usdcDeploymentGrant.data?.expiration, + usdcFeeAllowance.data?.allowance.expiration + ]); + + const fetch = useCallback( + async () => await Promise.all([invokeMap([uaktFeeAllowance, uaktDeploymentGrant, usdcFeeAllowance, usdcDeploymentGrant], "refetch")]), + [uaktFeeAllowance, uaktDeploymentGrant, usdcFeeAllowance, usdcDeploymentGrant] + ); + + return { + fetch, + uaktFeeLimit, + usdcFeeLimit, + uaktDeploymentLimit, + usdcDeploymentLimit, + expiration: earliestExpiration + }; +}; + +function extractDeploymentLimit(deploymentGrant?: ExactDeploymentAllowance) { + if (!deploymentGrant) { + return undefined; + } + + const isExpired = !isFuture(new Date(deploymentGrant.expiration)); + + if (isExpired) { + return undefined; + } + + return parseFloat(deploymentGrant?.authorization.spend_limit.amount); +} + +function extractFeeLimit(feeLimit?: FeeAllowance) { + if (!feeLimit) { + return undefined; + } + + const isExpired = !isFuture(new Date(feeLimit.allowance.expiration)); + + if (isExpired) { + return undefined; + } + + return parseFloat(feeLimit.allowance.spend_limit[0].amount); +} diff --git a/apps/deploy-web/src/hooks/useAutoTopUpService.ts b/apps/deploy-web/src/hooks/useAutoTopUpService.ts new file mode 100644 index 000000000..30cd48bb1 --- /dev/null +++ b/apps/deploy-web/src/hooks/useAutoTopUpService.ts @@ -0,0 +1,12 @@ +import { useMemo } from "react"; + +import { USDC_IBC_DENOMS } from "@src/config/denom.config"; +import { AutoTopUpMessageService } from "@src/services/auto-top-up-message/auto-top-up-message.service"; +import networkStore from "@src/store/networkStore"; + +export const useAutoTopUpService = () => { + const selectedNetworkId = networkStore.useSelectedNetworkId(); + const usdcDenom = USDC_IBC_DENOMS[selectedNetworkId]; + + return useMemo(() => new AutoTopUpMessageService(usdcDenom), [usdcDenom]); +}; diff --git a/apps/deploy-web/src/hooks/useDoubleTransactionAlert.tsx b/apps/deploy-web/src/hooks/useDoubleTransactionAlert.tsx new file mode 100644 index 000000000..3db405195 --- /dev/null +++ b/apps/deploy-web/src/hooks/useDoubleTransactionAlert.tsx @@ -0,0 +1,27 @@ +import React, { useMemo } from "react"; +import { Snackbar } from "@akashnetwork/ui/components"; +import { useSnackbar } from "notistack"; + +export const useDoubleTransactionAlert = () => { + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + + return useMemo( + () => ({ + show: () => { + const snackbarId = enqueueSnackbar( + , + { + variant: "warning", + persist: true + } + ); + return () => closeSnackbar(snackbarId); + } + }), + [closeSnackbar, enqueueSnackbar] + ); +}; diff --git a/apps/deploy-web/src/queries/useExactDeploymentGrantsQuery.ts b/apps/deploy-web/src/queries/useExactDeploymentGrantsQuery.ts new file mode 100644 index 000000000..4e07d7502 --- /dev/null +++ b/apps/deploy-web/src/queries/useExactDeploymentGrantsQuery.ts @@ -0,0 +1,8 @@ +import { useQuery } from "react-query"; + +import { useAllowanceService } from "@src/hooks/useAllowanceService"; + +export function useExactDeploymentGrantsQuery(granter: string, grantee: string, { enabled = true } = {}) { + const allowanceHttpService = useAllowanceService(); + return useQuery(["DeploymentGrant", granter, grantee], () => allowanceHttpService.getDeploymentGrantsForGranterAndGrantee(granter, grantee), { enabled }); +} diff --git a/apps/deploy-web/src/queries/useExactFeeAllowanceQuery.ts b/apps/deploy-web/src/queries/useExactFeeAllowanceQuery.ts new file mode 100644 index 000000000..851b3091a --- /dev/null +++ b/apps/deploy-web/src/queries/useExactFeeAllowanceQuery.ts @@ -0,0 +1,8 @@ +import { useQuery } from "react-query"; + +import { useAllowanceService } from "@src/hooks/useAllowanceService"; + +export function useExactFeeAllowanceQuery(granter: string, grantee: string, { enabled = true } = {}) { + const allowanceHttpService = useAllowanceService(); + return useQuery(["FeeAllowance", granter, grantee], () => allowanceHttpService.getFeeAllowanceForGranterAndGrantee(granter, grantee), { enabled }); +} diff --git a/apps/deploy-web/src/queries/useGrantsQuery.ts b/apps/deploy-web/src/queries/useGrantsQuery.ts index 31019aa08..0dd0e2152 100644 --- a/apps/deploy-web/src/queries/useGrantsQuery.ts +++ b/apps/deploy-web/src/queries/useGrantsQuery.ts @@ -1,5 +1,4 @@ import { QueryObserverResult, useQuery } from "react-query"; -import axios from "axios"; import { useSettings } from "@src/context/SettingsProvider"; import { AllowanceType, GrantType } from "@src/types/grant"; diff --git a/apps/deploy-web/src/services/auto-top-up-message/auto-top-up-message.service.ts b/apps/deploy-web/src/services/auto-top-up-message/auto-top-up-message.service.ts new file mode 100644 index 000000000..e494431c8 --- /dev/null +++ b/apps/deploy-web/src/services/auto-top-up-message/auto-top-up-message.service.ts @@ -0,0 +1,168 @@ +import { EncodeObject } from "@cosmjs/proto-signing"; + +import { browserEnvConfig } from "@src/config/browser-env.config"; +import { TransactionMessageData } from "@src/utils/TransactionMessageData"; + +interface TypeSplitMessages { + revoke?: EncodeObject; + grant?: EncodeObject; +} + +interface LimitCollectorInput { + granter: string; + grantee: string; + denom?: string; + prev?: { + limit?: number; + expiration?: Date; + }; + next?: { + limit: number; + expiration: Date; + }; +} + +interface LimitState { + uaktFeeLimit: number; + usdcFeeLimit: number; + uaktDeploymentLimit: number; + usdcDeploymentLimit: number; + expiration: Date; +} + +interface CollectionInput { + granter: string; + prev: Partial; + next?: LimitState; +} + +interface CollectionResult { + revoke: EncodeObject[]; + grant: EncodeObject[]; +} + +export class AutoTopUpMessageService { + constructor(private readonly usdcDenom: string) {} + + collectMessages(options: CollectionInput): CollectionResult { + const uaktSides = { + granter: options.granter, + grantee: browserEnvConfig.NEXT_PUBLIC_UAKT_TOP_UP_MASTER_WALLET_ADDRESS + }; + const usdcSides = { + granter: options.granter, + grantee: browserEnvConfig.NEXT_PUBLIC_USDC_TOP_UP_MASTER_WALLET_ADDRESS + }; + + const { revoke, grant } = [ + this.collectFeeMessages({ + ...uaktSides, + prev: { + limit: options.prev.uaktFeeLimit, + expiration: options.prev.expiration + }, + next: options.next && { + limit: options.next.uaktFeeLimit, + expiration: options.next.expiration + } + }), + this.collectFeeMessages({ + ...usdcSides, + prev: { + limit: options.prev.usdcFeeLimit, + expiration: options.prev.expiration + }, + next: options.next && { + limit: options.next.usdcFeeLimit, + expiration: options.next.expiration + } + }), + this.collectDeploymentMessages({ + ...uaktSides, + denom: "uakt", + prev: { + limit: options.prev.uaktDeploymentLimit, + expiration: options.prev.expiration + }, + next: options.next && { + limit: options.next.uaktDeploymentLimit, + expiration: options.next.expiration + } + }), + this.collectDeploymentMessages({ + ...usdcSides, + denom: this.usdcDenom, + prev: { + limit: options.prev.usdcDeploymentLimit, + expiration: options.prev.expiration + }, + next: options.next && { + limit: options.next.usdcDeploymentLimit, + expiration: options.next.expiration + } + }) + ].reduce( + (acc, curr) => { + if (curr.revoke) { + acc.revoke.push(curr.revoke); + } + + if (curr.grant) { + acc.grant.push(curr.grant); + } + + return acc; + }, + { revoke: [], grant: [] } as CollectionResult + ); + + const hasFeesRevoke = revoke.some(msg => msg.typeUrl === TransactionMessageData.Types.MSG_REVOKE_ALLOWANCE); + const hasFeesGrant = grant.some(msg => msg.typeUrl === TransactionMessageData.Types.MSG_GRANT_ALLOWANCE); + + return hasFeesRevoke && hasFeesGrant ? { revoke, grant } : { revoke: [], grant: [...revoke, ...grant] }; + } + + private collectFeeMessages(options: LimitCollectorInput): TypeSplitMessages { + const messages: TypeSplitMessages = {}; + const isSameExpiration = options.prev?.expiration?.getTime() === options.next?.expiration.getTime(); + const isSameLimit = options.prev?.limit === options.next?.limit; + + if (isSameExpiration && isSameLimit) { + return messages; + } + + if (typeof options.prev?.limit !== "undefined") { + messages.revoke = TransactionMessageData.getRevokeAllowanceMsg(options.granter, options.grantee); + } + + if (options.next?.limit) { + messages.grant = TransactionMessageData.getGrantBasicAllowanceMsg(options.granter, options.grantee, options.next.limit, "uakt", options.next.expiration); + } + + return messages; + } + + private collectDeploymentMessages(options: LimitCollectorInput): TypeSplitMessages { + const messages: TypeSplitMessages = {}; + const isSameExpiration = options.prev?.expiration?.getTime() === options.next?.expiration.getTime(); + const isSameLimit = options.prev?.limit === options.next?.limit; + + if (isSameExpiration && isSameLimit) { + return messages; + } + + if (options.next?.limit) { + messages.grant = TransactionMessageData.getGrantMsg( + options.granter, + options.grantee, + options.next.limit, + options.next.expiration, + options.denom || "uakt" + ); + } else if (typeof options.prev?.limit !== "undefined") { + messages.revoke = TransactionMessageData.getRevokeMsg(options.granter, options.grantee, "/akash.deployment.v1beta3.DepositDeploymentAuthorization"); + } + + return messages; + } +} diff --git a/packages/http-sdk/src/allowance/allowance-http.service.ts b/packages/http-sdk/src/allowance/allowance-http.service.ts index 402ba3d05..cd2b7d066 100644 --- a/packages/http-sdk/src/allowance/allowance-http.service.ts +++ b/packages/http-sdk/src/allowance/allowance-http.service.ts @@ -18,14 +18,17 @@ export interface FeeAllowance { }; } -export interface DeploymentAllowance { - granter: string; - grantee: string; +export interface ExactDeploymentAllowance { authorization: { "@type": "/akash.deployment.v1beta3.DepositDeploymentAuthorization"; spend_limit: SpendLimit; - expiration: string; }; + expiration: string; +} + +export interface DeploymentAllowance extends ExactDeploymentAllowance { + granter: string; + grantee: string; } interface FeeAllowanceListResponse { @@ -36,8 +39,8 @@ interface FeeAllowanceResponse { allowance: FeeAllowance; } -interface DeploymentAllowanceResponse { - grants: DeploymentAllowance[]; +interface DeploymentAllowanceResponse { + grants: T[]; pagination: { next_key: string | null; }; @@ -54,8 +57,16 @@ export class AllowanceHttpService extends HttpService { } async getFeeAllowanceForGranterAndGrantee(granter: string, grantee: string) { - const allowances = this.extractData(await this.get(`cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`)); - return allowances.allowance.allowance["@type"] === "/cosmos.feegrant.v1beta1.BasicAllowance" ? allowances.allowance : undefined; + try { + const allowances = this.extractData(await this.get(`cosmos/feegrant/v1beta1/allowance/${granter}/${grantee}`)); + return allowances.allowance.allowance["@type"] === "/cosmos.feegrant.v1beta1.BasicAllowance" ? allowances.allowance : undefined; + } catch (error) { + if (error.response.data.message.includes("fee-grant not found")) { + return undefined; + } + + throw error; + } } async getDeploymentAllowancesForGrantee(address: string) { @@ -63,6 +74,18 @@ export class AllowanceHttpService extends HttpService { return allowances.grants.filter(grant => grant.authorization["@type"] === "/akash.deployment.v1beta3.DepositDeploymentAuthorization"); } + async getDeploymentGrantsForGranterAndGrantee(granter: string, grantee: string) { + const allowances = this.extractData( + await this.get>("cosmos/authz/v1beta1/grants", { + params: { + grantee: grantee, + granter: granter + } + }) + ); + return allowances.grants.find(grant => grant.authorization["@type"] === "/akash.deployment.v1beta3.DepositDeploymentAuthorization"); + } + async hasFeeAllowance(granter: string, grantee: string) { const feeAllowances = await this.getFeeAllowancesForGrantee(grantee); return feeAllowances.some(allowance => allowance.granter === granter); diff --git a/packages/ui/components/input.tsx b/packages/ui/components/input.tsx index 83629b5d4..98d27b003 100644 --- a/packages/ui/components/input.tsx +++ b/packages/ui/components/input.tsx @@ -10,14 +10,15 @@ export interface FormInputProps extends InputProps { label?: string | React.ReactNode; description?: string; inputClassName?: string; + dirty?: boolean; } -const FormInput = React.forwardRef(({ className, inputClassName, type, label, description, ...props }, ref) => { +const FormInput = React.forwardRef(({ className, inputClassName, type, label, description, dirty, ...props }, ref) => { const { error } = useFormField(); return ( - + {description && {description}} diff --git a/packages/ui/components/switch.tsx b/packages/ui/components/switch.tsx index 6908b5d8f..d7f224394 100644 --- a/packages/ui/components/switch.tsx +++ b/packages/ui/components/switch.tsx @@ -41,7 +41,7 @@ const SwitchWithLabel = React.forwardRef< ); return ( -
+
{labelPosition === "left" && _label} {labelPosition === "right" && _label}