From 4c540da471f1b9db32c941947ce56d8d25eae6c5 Mon Sep 17 00:00:00 2001 From: Yaroslav Grishajev Date: Wed, 12 Jun 2024 15:44:37 +0200 Subject: [PATCH] feat(wallet): add fee granter to transaction signer As a part of this add select modal prompt refs #219 --- .../src/components/shared/Popup.tsx | 72 ++++++++++++++++++- .../context/PopupProvider/PopupProvider.tsx | 43 ++++++++++- .../context/WalletProvider/WalletProvider.tsx | 42 ++++++++++- apps/deploy-web/src/pages/_app.tsx | 30 ++++---- apps/deploy-web/src/queries/useGrantsQuery.ts | 11 ++- .../src/utils/TransactionMessageData.ts | 3 +- 6 files changed, 173 insertions(+), 28 deletions(-) diff --git a/apps/deploy-web/src/components/shared/Popup.tsx b/apps/deploy-web/src/components/shared/Popup.tsx index 0e3251f5c..fab964da2 100644 --- a/apps/deploy-web/src/components/shared/Popup.tsx +++ b/apps/deploy-web/src/components/shared/Popup.tsx @@ -1,7 +1,9 @@ import * as React from "react"; +import { useMemo } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { DialogProps } from "@radix-ui/react-dialog"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui/select"; import { cn } from "@src/utils/styleUtils"; import { Button, ButtonProps } from "../ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle as _DialogTitle } from "../ui/dialog"; @@ -32,6 +34,21 @@ type CustomPrompt = { actions: ActionButton[]; }; +export type SelectOption = { + text: string; + value: string; + selected?: boolean; + disabled?: boolean; +}; + +export type SelectProps = { + variant: "select"; + options: SelectOption[]; + placeholder?: string; + onValidate: (value: string | undefined) => void; + onCancel: () => void; +}; + export type TOnCloseHandler = { (event: any, reason: "backdropClick" | "escapeKeyDown" | "action"): void; }; @@ -58,7 +75,7 @@ export type ActionButton = ButtonProps & { isLoading?: boolean; }; -export type PopupProps = (MessageProps | ConfirmProps | PromptProps | CustomPrompt) & CommonProps; +export type PopupProps = (MessageProps | ConfirmProps | PromptProps | CustomPrompt | SelectProps) & CommonProps; export interface DialogTitleProps { children: React.ReactNode; @@ -78,6 +95,8 @@ export const DialogTitle = (props: DialogTitleProps) => { export function Popup(props: React.PropsWithChildren) { const [promptInput, setPromptInput] = React.useState(""); + const initialOption = useMemo(() => (props.variant === "select" ? props.options.find(option => option.selected)?.value : undefined), [props]); + const [selectOption, setSelectOption] = React.useState(initialOption); const component = [] as JSX.Element[]; const onClose: TOnCloseHandler = (event, reason) => { @@ -97,7 +116,30 @@ export function Popup(props: React.PropsWithChildren) { component.push({props.title}); } - if (props.message && props.variant !== "prompt") { + if (props.variant === "select") { + component.push( + + {props.message} + {props.variant === "select" ? ( + + ) : null} + + + ); + } else if (props.message && props.variant !== "prompt") { component.push( {props.message} @@ -215,6 +257,32 @@ export function Popup(props: React.PropsWithChildren) { ); break; } + case "select": { + component.push( + + + + + ); + break; + } } /** diff --git a/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx b/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx index 57d8f0843..a0480ab0d 100644 --- a/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx +++ b/apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx @@ -1,13 +1,15 @@ import React, { useCallback, useMemo, useState } from "react"; import { firstValueFrom, Subject } from "rxjs"; -import { CommonProps, ConfirmProps, Popup, PopupProps } from "@src/components/shared/Popup"; +import { CommonProps, ConfirmProps, Popup, PopupProps, SelectOption, SelectProps } from "@src/components/shared/Popup"; import type { FCWithChildren } from "@src/types/component"; type ConfirmPopupProps = string | (Omit & Omit); +type SelectPopupProps = Omit & Omit; type PopupProviderContext = { confirm: (messageOrProps: ConfirmPopupProps) => Promise; + select: (props: SelectPopupProps) => Promise; }; const PopupContext = React.createContext(undefined); @@ -15,7 +17,7 @@ const PopupContext = React.createContext(undef export const PopupProvider: FCWithChildren = ({ children }) => { const [popupProps, setPopupProps] = useState(); - const confirm = useCallback( + const confirm: PopupProviderContext["confirm"] = useCallback( (messageOrProps: ConfirmPopupProps) => { let subject: Subject | undefined = new Subject(); @@ -45,7 +47,42 @@ export const PopupProvider: FCWithChildren = ({ children }) => { [setPopupProps] ); - const context = useMemo(() => ({ confirm }), [confirm]); + const select: PopupProviderContext["select"] = useCallback( + props => { + let subject: Subject | undefined = new Subject(); + + const reject = () => { + if (subject) { + subject.next(undefined); + subject.complete(); + setPopupProps(undefined); + subject = undefined; + } + }; + + setPopupProps({ + title: "Confirm", + ...props, + open: true, + variant: "select", + onValidate: value => { + if (subject) { + subject.next(value); + subject.complete(); + setPopupProps(undefined); + subject = undefined; + } + }, + onCancel: reject, + onClose: reject + }); + + return firstValueFrom(subject); + }, + [setPopupProps] + ); + + const context = useMemo(() => ({ confirm, select }), [confirm]); return ( diff --git a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx index 70091110f..799ce9493 100644 --- a/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx +++ b/apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx @@ -1,10 +1,12 @@ "use client"; -import React, { useRef } from "react"; +import React, { useMemo, useRef } from "react"; import { useEffect, useState } from "react"; import { EncodeObject } from "@cosmjs/proto-signing"; import { SigningStargateClient } from "@cosmjs/stargate"; import { useManager } from "@cosmos-kit/react"; import axios from "axios"; +import isAfter from "date-fns/isAfter"; +import parseISO from "date-fns/parseISO"; import { OpenNewWindow } from "iconoir-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -12,12 +14,17 @@ import { event } from "nextjs-google-analytics"; import { SnackbarKey, useSnackbar } from "notistack"; import { TransactionModal } from "@src/components/layout/TransactionModal"; +import { SelectOption } from "@src/components/shared/Popup"; import { Snackbar } from "@src/components/shared/Snackbar"; +import { usePopup } from "@src/context/PopupProvider/PopupProvider"; import { useUsdcDenom } from "@src/hooks/useDenom"; import { getSelectedNetwork, useSelectedNetwork } from "@src/hooks/useSelectedNetwork"; +import { useAllowancesGranted } from "@src/queries/useGrantsQuery"; import { AnalyticsEvents } from "@src/utils/analytics"; import { STATS_APP_URL, uAktDenom } from "@src/utils/constants"; import { customRegistry } from "@src/utils/customRegistry"; +import { udenomToDenom } from "@src/utils/mathHelpers"; +import { coinToUDenom } from "@src/utils/priceUtils"; import { UrlService } from "@src/utils/urlUtils"; import { LocalWalletDataType } from "@src/utils/walletUtils"; import { useSelectedChain } from "../CustomChainProvider"; @@ -55,6 +62,32 @@ export const WalletProvider = ({ children }) => { const usdcIbcDenom = useUsdcDenom(); const { disconnect, getOfflineSigner, isWalletConnected, address: walletAddress, connect, username, estimateFee, sign, broadcast } = useSelectedChain(); const { addEndpoints } = useManager(); + const { data: allowancesGranted } = useAllowancesGranted(walletAddress); + + const feeGranters = useMemo(() => { + if (!walletAddress || !allowancesGranted) { + return; + } + + const connectedWallet: SelectOption = { text: "Connected Wallet", value: walletAddress }; + const options: SelectOption[] = allowancesGranted.reduce( + (acc, grant, index) => { + if (isAfter(parseISO(grant.allowance.expiration), new Date())) { + acc.push({ + text: `${grant.granter} (${udenomToDenom(coinToUDenom(grant.allowance.spend_limit[0]), 6)} AKT)`, + value: grant.granter, + selected: index === 0 + }); + } + + return acc; + }, + [connectedWallet] + ); + + return options?.length > 1 ? options : undefined; + }, [allowancesGranted, walletAddress]); + const { select } = usePopup(); useEffect(() => { if (!settings.apiEndpoint || !settings.rpcEndpoint) return; @@ -160,8 +193,11 @@ export const WalletProvider = ({ children }) => { let pendingSnackbarKey: SnackbarKey | null = null; try { const estimatedFees = await estimateFee(msgs); - - const txRaw = await sign(msgs, estimatedFees); + const feeGranter = feeGranters && (await select({ title: "Select fee granter", options: feeGranters })); + const txRaw = await sign(msgs, { + ...estimatedFees, + granter: feeGranter + }); setIsWaitingForApproval(false); setIsBroadcastingTx(true); diff --git a/apps/deploy-web/src/pages/_app.tsx b/apps/deploy-web/src/pages/_app.tsx index 28cf49cd1..a11af0fb2 100644 --- a/apps/deploy-web/src/pages/_app.tsx +++ b/apps/deploy-web/src/pages/_app.tsx @@ -64,22 +64,22 @@ const App: React.FunctionComponent = props => { - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + diff --git a/apps/deploy-web/src/queries/useGrantsQuery.ts b/apps/deploy-web/src/queries/useGrantsQuery.ts index 699f62ed5..e2bc1cfab 100644 --- a/apps/deploy-web/src/queries/useGrantsQuery.ts +++ b/apps/deploy-web/src/queries/useGrantsQuery.ts @@ -1,7 +1,9 @@ -import { useQuery } from "react-query"; +import { QueryObserverResult, useQuery } from "react-query"; import axios from "axios"; import { useSettings } from "@src/context/SettingsProvider"; +import { Coin } from "@src/types"; +import { AllowanceType } from "@src/types/grant"; import { ApiUrlService } from "@src/utils/apiUtils"; import { QueryKeys } from "./queryKeys"; @@ -67,8 +69,11 @@ async function getAllowancesGranted(apiEndpoint: string, address: string) { return response.data.allowances; } -export function useAllowancesGranted(address: string, options = {}) { +export function useAllowancesGranted(address?: string, options = {}): QueryObserverResult { const { settings } = useSettings(); - return useQuery(QueryKeys.getAllowancesGranted(address), () => getAllowancesGranted(settings.apiEndpoint, address), options); + return useQuery(address ? QueryKeys.getAllowancesGranted(address) : "", () => (address ? getAllowancesGranted(settings.apiEndpoint, address) : undefined), { + ...options, + enabled: !!address + }); } diff --git a/apps/deploy-web/src/utils/TransactionMessageData.ts b/apps/deploy-web/src/utils/TransactionMessageData.ts index 4aeff8d60..476eadf99 100644 --- a/apps/deploy-web/src/utils/TransactionMessageData.ts +++ b/apps/deploy-web/src/utils/TransactionMessageData.ts @@ -1,4 +1,3 @@ -import { longify } from "@cosmjs/stargate/build/queryclient"; import Long from "long"; import { BidDto } from "@src/types/deployment"; @@ -222,7 +221,7 @@ export class TransactionMessageData { ], expiration: expiration ? { - seconds: longify(Math.floor(expiration.getTime() / 1_000)) as unknown as Long, + seconds: Long.fromInt(Math.floor(expiration.getTime() / 1_000)) as unknown as Long, nanos: Math.floor((expiration.getTime() % 1_000) * 1_000_000) } : undefined