Skip to content

Commit

Permalink
feat(wallet): add fee granter to transaction signer
Browse files Browse the repository at this point in the history
As a part of this add select modal prompt

refs #219
  • Loading branch information
ygrishajev committed Jun 13, 2024
1 parent f23e3b8 commit 4c540da
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 28 deletions.
72 changes: 70 additions & 2 deletions apps/deploy-web/src/components/shared/Popup.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
};
Expand All @@ -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;
Expand All @@ -78,6 +95,8 @@ export const DialogTitle = (props: DialogTitleProps) => {

export function Popup(props: React.PropsWithChildren<PopupProps>) {
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<SelectOption["value"] | undefined>(initialOption);
const component = [] as JSX.Element[];

const onClose: TOnCloseHandler = (event, reason) => {
Expand All @@ -97,7 +116,30 @@ export function Popup(props: React.PropsWithChildren<PopupProps>) {
component.push(<DialogTitle key="dialog-title">{props.title}</DialogTitle>);
}

if (props.message && props.variant !== "prompt") {
if (props.variant === "select") {
component.push(
<ScrollArea className="max-h-[75vh]" key="dialog-content">
{props.message}
{props.variant === "select" ? (
<Select value={selectOption} onValueChange={setSelectOption}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{props.options.map((option: SelectOption) => (
<SelectItem key={option.value} value={option.value} disabled={option.disabled}>
{option.text}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : null}
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
} else if (props.message && props.variant !== "prompt") {
component.push(
<ScrollArea className="max-h-[75vh]" key="dialog-content">
{props.message}
Expand Down Expand Up @@ -215,6 +257,32 @@ export function Popup(props: React.PropsWithChildren<PopupProps>) {
);
break;
}
case "select": {
component.push(
<DialogFooter key="DialogActions" className="justify-between">
<Button
variant="ghost"
onClick={() => {
props.onCancel();
onClose(null, "action");
}}
>
{CancelButtonLabel}
</Button>
<Button
variant="default"
color="primary"
onClick={() => {
props.onValidate(selectOption);
onClose(null, "action");
}}
>
{ConfirmButtonLabel}
</Button>
</DialogFooter>
);
break;
}
}

/**
Expand Down
43 changes: 40 additions & 3 deletions apps/deploy-web/src/context/PopupProvider/PopupProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
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<CommonProps, "onClose" | "open"> & Omit<ConfirmProps, "onValidate" | "onCancel" | "variant">);
type SelectPopupProps = Omit<CommonProps, "onClose" | "open"> & Omit<SelectProps, "onValidate" | "onCancel" | "variant">;

type PopupProviderContext = {
confirm: (messageOrProps: ConfirmPopupProps) => Promise<boolean>;
select: (props: SelectPopupProps) => Promise<string | undefined>;
};

const PopupContext = React.createContext<PopupProviderContext | undefined>(undefined);

export const PopupProvider: FCWithChildren = ({ children }) => {
const [popupProps, setPopupProps] = useState<PopupProps | undefined>();

const confirm = useCallback(
const confirm: PopupProviderContext["confirm"] = useCallback(
(messageOrProps: ConfirmPopupProps) => {
let subject: Subject<boolean> | undefined = new Subject<boolean>();

Expand Down Expand Up @@ -45,7 +47,42 @@ export const PopupProvider: FCWithChildren = ({ children }) => {
[setPopupProps]
);

const context = useMemo(() => ({ confirm }), [confirm]);
const select: PopupProviderContext["select"] = useCallback(
props => {
let subject: Subject<SelectOption["value"] | undefined> | undefined = new Subject<SelectOption["value"] | undefined>();

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 (
<PopupContext.Provider value={context}>
Expand Down
42 changes: 39 additions & 3 deletions apps/deploy-web/src/context/WalletProvider/WalletProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
"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";
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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 15 additions & 15 deletions apps/deploy-web/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,22 @@ const App: React.FunctionComponent<Props> = props => {
<TooltipProvider>
<SettingsProvider>
<CustomChainProvider>
<WalletProvider>
<ChainParamProvider>
<CertificateProvider>
<BackgroundTaskProvider>
<TemplatesProvider>
<LocalNoteProvider>
<GoogleAnalytics />
<PopupProvider>
<PopupProvider>
<WalletProvider>
<ChainParamProvider>
<CertificateProvider>
<BackgroundTaskProvider>
<TemplatesProvider>
<LocalNoteProvider>
<GoogleAnalytics />
<Component {...pageProps} />
</PopupProvider>
</LocalNoteProvider>
</TemplatesProvider>
</BackgroundTaskProvider>
</CertificateProvider>
</ChainParamProvider>
</WalletProvider>
</LocalNoteProvider>
</TemplatesProvider>
</BackgroundTaskProvider>
</CertificateProvider>
</ChainParamProvider>
</WalletProvider>
</PopupProvider>
</CustomChainProvider>
</SettingsProvider>
</TooltipProvider>
Expand Down
11 changes: 8 additions & 3 deletions apps/deploy-web/src/queries/useGrantsQuery.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<AllowanceType[]> {
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
});
}
3 changes: 1 addition & 2 deletions apps/deploy-web/src/utils/TransactionMessageData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { longify } from "@cosmjs/stargate/build/queryclient";
import Long from "long";

import { BidDto } from "@src/types/deployment";
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 4c540da

Please sign in to comment.