From 30f67eb1b554da4ba8e8888adb9aafb6fb778e56 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 --- .../test/seeders/deployment-grant.seeder.ts | 6 +- apps/deploy-web/next-env.d.ts | 2 +- .../authorizations/Authorizations.tsx | 15 +- .../AutoTopUpSetting/AutoTopUpSetting.tsx | 218 ++++++++++++++++++ .../AutoTopUpSettingContainer.tsx | 52 +++++ .../components/settings/SettingsContainer.tsx | 6 + .../src/components/settings/SettingsForm.tsx | 8 +- .../src/hooks/useAllowanceService.tsx | 9 + .../src/hooks/useAutoTopUpLimits.tsx | 91 ++++++++ .../src/hooks/useAutoTopUpService.ts | 12 + apps/deploy-web/src/queries/queryKeys.ts | 3 + .../queries/useExactDeploymentGrantsQuery.ts | 11 + .../src/queries/useExactFeeAllowanceQuery.ts | 11 + apps/deploy-web/src/queries/useGrantsQuery.ts | 1 - .../auto-top-up-message.service.ts | 134 +++++++++++ package-lock.json | 4 +- .../src/allowance/allowance-http.service.ts | 39 +++- packages/ui/components/input.tsx | 12 +- packages/ui/components/switch.tsx | 2 +- 19 files changed, 610 insertions(+), 26 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/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/api/test/seeders/deployment-grant.seeder.ts b/apps/api/test/seeders/deployment-grant.seeder.ts index 5ef4914c1..f99dd2f15 100644 --- a/apps/api/test/seeders/deployment-grant.seeder.ts +++ b/apps/api/test/seeders/deployment-grant.seeder.ts @@ -18,9 +18,9 @@ export class DeploymentGrantSeeder { spend_limit: { denom: DenomSeeder.create(), amount: faker.number.int({ min: 0, max: 10000000 }).toString() - }, - expiration: faker.date.future().toISOString() - } + } + }, + expiration: faker.date.future().toISOString() }, input ); diff --git a/apps/deploy-web/next-env.d.ts b/apps/deploy-web/next-env.d.ts index 4f11a03dc..a4a7b3f5c 100644 --- a/apps/deploy-web/next-env.d.ts +++ b/apps/deploy-web/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. 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..5839f8bf1 --- /dev/null +++ b/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSetting.tsx @@ -0,0 +1,218 @@ +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 ( +
+
+ +
Deployments billed in AKT
+
+
+ { + return ; + }} + /> +
+ +
+ { + return ; + }} + /> +
+
+ +
Deployments billed in USDC
+
+
+ { + 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..5eb6e0e2b --- /dev/null +++ b/apps/deploy-web/src/components/settings/AutoTopUpSetting/AutoTopUpSettingContainer.tsx @@ -0,0 +1,52 @@ +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"; + +export const AutoTopUpSettingContainer: FC = () => { + const { address, signAndBroadcastTx } = useWallet(); + const { fetch, uaktFeeLimit, usdcFeeLimit, uaktDeploymentLimit, usdcDeploymentLimit, expiration } = useAutoTopUpLimits(); + const autoTopUpMessageService = useAutoTopUpService(); + + useEffect(() => { + fetch(); + }, []); + + const updateAllowancesAndGrants: AutoTopUpSettingProps["onSubmit"] = useCallback( + async (action, next) => { + const prev = { + uaktFeeLimit, + usdcFeeLimit, + uaktDeploymentLimit, + usdcDeploymentLimit, + expiration + }; + + const messages = autoTopUpMessageService.collectMessages({ + granter: address, + prev, + next: action === "revoke-all" ? undefined : { ...next, expiration: new Date(next.expiration) } + }); + + if (messages.length) { + await signAndBroadcastTx(messages); + } + + await fetch(); + }, + [address, autoTopUpMessageService, 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..c40fcb96a 100644 --- a/apps/deploy-web/src/components/settings/SettingsForm.tsx +++ b/apps/deploy-web/src/components/settings/SettingsForm.tsx @@ -94,9 +94,7 @@ export const SettingsForm: React.FunctionComponent = () => { control={control} name="apiEndpoint" defaultValue={settings.apiEndpoint} - render={({ field }) => { - return ; - }} + render={({ field }) => } /> ) : (

{settings.apiEndpoint}

@@ -111,9 +109,7 @@ export const SettingsForm: React.FunctionComponent = () => { control={control} name="rpcEndpoint" defaultValue={settings.rpcEndpoint} - render={({ field }) => { - return ; - }} + render={({ field }) => } /> ) : (

{settings.rpcEndpoint}

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/queries/queryKeys.ts b/apps/deploy-web/src/queries/queryKeys.ts index 1f9cba09d..efc9b8a51 100644 --- a/apps/deploy-web/src/queries/queryKeys.ts +++ b/apps/deploy-web/src/queries/queryKeys.ts @@ -60,4 +60,7 @@ export class QueryKeys { static getBranchesKey = (repo?: string, accessToken?: string | null) => ["BRANCHES", repo, accessToken]; static getPackageJsonKey = (repo?: string, branch?: string, subFolder?: string) => ["PACKAGE_JSON", repo, branch, subFolder]; static getSrcFoldersKey = (repo?: string, branch?: string) => ["SRC_FOLDERS", repo, branch]; + + static getDeploymentGrantsKey = (granter: string, grantee: string) => ["DEPLOYMENT_GRANT", granter, grantee]; + static getFeeAllowancesKey = (granter: string, grantee: string) => ["FEE_ALLOWANCE", granter, grantee]; } diff --git a/apps/deploy-web/src/queries/useExactDeploymentGrantsQuery.ts b/apps/deploy-web/src/queries/useExactDeploymentGrantsQuery.ts new file mode 100644 index 000000000..4c1dd3225 --- /dev/null +++ b/apps/deploy-web/src/queries/useExactDeploymentGrantsQuery.ts @@ -0,0 +1,11 @@ +import { useQuery } from "react-query"; + +import { useAllowanceService } from "@src/hooks/useAllowanceService"; +import { QueryKeys } from "@src/queries/queryKeys"; + +export function useExactDeploymentGrantsQuery(granter: string, grantee: string, { enabled = true } = {}) { + const allowanceHttpService = useAllowanceService(); + return useQuery(QueryKeys.getDeploymentGrantsKey(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..7d6bacb3b --- /dev/null +++ b/apps/deploy-web/src/queries/useExactFeeAllowanceQuery.ts @@ -0,0 +1,11 @@ +import { useQuery } from "react-query"; + +import { useAllowanceService } from "@src/hooks/useAllowanceService"; +import { QueryKeys } from "@src/queries/queryKeys"; + +export function useExactFeeAllowanceQuery(granter: string, grantee: string, { enabled = true } = {}) { + const allowanceHttpService = useAllowanceService(); + return useQuery(QueryKeys.getFeeAllowancesKey(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..1ef927162 --- /dev/null +++ b/apps/deploy-web/src/services/auto-top-up-message/auto-top-up-message.service.ts @@ -0,0 +1,134 @@ +import { EncodeObject } from "@cosmjs/proto-signing"; + +import { browserEnvConfig } from "@src/config/browser-env.config"; +import { TransactionMessageData } from "@src/utils/TransactionMessageData"; + +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; +} + +export class AutoTopUpMessageService { + constructor(private readonly usdcDenom: string) {} + + collectMessages(options: CollectionInput): EncodeObject[] { + 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 + }; + + return [ + ...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 + } + }) + ]; + } + + private collectFeeMessages(options: LimitCollectorInput): EncodeObject[] { + const messages: EncodeObject[] = []; + 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.push(TransactionMessageData.getRevokeAllowanceMsg(options.granter, options.grantee)); + } + + if (options.next?.limit) { + messages.push(TransactionMessageData.getGrantBasicAllowanceMsg(options.granter, options.grantee, options.next.limit, "uakt", options.next.expiration)); + } + + return messages; + } + + private collectDeploymentMessages(options: LimitCollectorInput): EncodeObject[] { + const messages: EncodeObject[] = []; + 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.push(TransactionMessageData.getGrantMsg(options.granter, options.grantee, options.next.limit, options.next.expiration, options.denom || "uakt")); + } else if (typeof options.prev?.limit !== "undefined") { + messages.push(TransactionMessageData.getRevokeMsg(options.granter, options.grantee, "/akash.deployment.v1beta3.DepositDeploymentAuthorization")); + } + + return messages; + } +} diff --git a/package-lock.json b/package-lock.json index fa2522a47..09e20d347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ }, "apps/api": { "name": "@akashnetwork/console-api", - "version": "2.33.0", + "version": "2.33.1-beta.0", "license": "Apache-2.0", "dependencies": { "@akashnetwork/akash-api": "^1.3.0", @@ -228,7 +228,7 @@ }, "apps/deploy-web": { "name": "@akashnetwork/console-web", - "version": "2.24.1-beta.1", + "version": "2.24.1-beta.2", "license": "Apache-2.0", "dependencies": { "@akashnetwork/akash-api": "^1.3.0", 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..828350d22 100644 --- a/packages/ui/components/input.tsx +++ b/packages/ui/components/input.tsx @@ -10,14 +10,22 @@ 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}