diff --git a/apps/namadillo/src/App/Ibc/IbcTransfer.tsx b/apps/namadillo/src/App/Ibc/IbcTransfer.tsx index 431d415cd..435831ae4 100644 --- a/apps/namadillo/src/App/Ibc/IbcTransfer.tsx +++ b/apps/namadillo/src/App/Ibc/IbcTransfer.tsx @@ -184,7 +184,7 @@ export const IbcTransfer = (): JSX.Element => { isShielded: shielded, onChangeShielded: setShielded, }} - gasConfig={gasConfig} + gasConfig={gasConfig.data} changeFeeEnabled={false} submittingText={currentProgress} isSubmitting={transferStatus === "pending" || !!currentProgress} diff --git a/apps/namadillo/src/App/Transfer/TransferModule.tsx b/apps/namadillo/src/App/Transfer/TransferModule.tsx index 17ad60adf..0481ac730 100644 --- a/apps/namadillo/src/App/Transfer/TransferModule.tsx +++ b/apps/namadillo/src/App/Transfer/TransferModule.tsx @@ -148,8 +148,6 @@ export const TransferModule = ({ return "NoSelectedAsset"; } else if (!source.amount || source.amount.eq(0)) { return "NoAmount"; - } else if (!gasConfig) { - return "NoTransactionFee"; } else if ( !availableAmountMinusFees || source.amount.gt(availableAmountMinusFees) diff --git a/apps/namadillo/src/atoms/integrations/services.ts b/apps/namadillo/src/atoms/integrations/services.ts index 8a2cf9cf9..f255bb469 100644 --- a/apps/namadillo/src/atoms/integrations/services.ts +++ b/apps/namadillo/src/atoms/integrations/services.ts @@ -4,6 +4,7 @@ import { assertIsDeliverTxSuccess, calculateFee, DeliverTxResponse, + MsgTransferEncodeObject, SigningStargateClient, StargateClient, StdFee, @@ -15,7 +16,6 @@ import { getIndexerApi } from "atoms/api"; import { queryForAck, queryForIbcTimeout } from "atoms/transactions"; import BigNumber from "bignumber.js"; import { getDefaultStore } from "jotai"; -import { createIbcTransferMessage } from "lib/transactions"; import toml from "toml"; import { AddressWithAsset, @@ -25,7 +25,6 @@ import { LocalnetToml, TransferStep, } from "types"; -import { toBaseAmount } from "utils"; import { getKeplrWallet } from "utils/ibc"; import { getSdkInstance } from "utils/sdk"; import { rpcByChainAtom } from "./atoms"; @@ -105,35 +104,14 @@ export const createStargateClient = async ( export const getSignedMessage = async ( client: SigningStargateClient, - transferParams: IbcTransferParams, - maspCompatibleMemo: string = "" + transferMsg: MsgTransferEncodeObject, + gasConfig: GasConfig ): Promise => { - const { - sourceAddress, - amount: displayAmount, - asset, - sourceChannelId, - gasConfig, - } = transferParams; - - // cosmjs expects amounts to be represented in the base denom, so convert - const baseAmount = toBaseAmount(asset.asset, displayAmount); - const fee: StdFee = calculateFee( - gasConfig.gasLimit.toNumber(), + Math.ceil(gasConfig.gasLimit.toNumber()), `${gasConfig.gasPrice.toString()}${gasConfig.gasToken}` ); - - const transferMsg = createIbcTransferMessage( - sourceChannelId, - sourceAddress, - transferParams.destinationAddress, - baseAmount, - asset.originalAddress, - maspCompatibleMemo - ); - - return await client.sign(sourceAddress, [transferMsg], fee, ""); + return await client.sign(transferMsg.value.sender!, [transferMsg], fee, ""); }; export const broadcastIbcTransaction = async ( @@ -281,3 +259,17 @@ export const fetchIbcChannelFromRegistry = async ( const channelInfo: IBCInfo = await (await fetch(queryUrl.toString())).json(); return getChannelFromIbcInfo(ibcChainName, channelInfo) || null; }; + +export const simulateIbcTransferFee = async ( + stargateClient: SigningStargateClient, + sourceAddress: string, + transferMsg: MsgTransferEncodeObject, + additionalPercentage: number = 0.05 +): Promise => { + const estimatedGas = await stargateClient.simulate( + sourceAddress!, + [transferMsg], + undefined + ); + return estimatedGas * (1 + additionalPercentage); +}; diff --git a/apps/namadillo/src/constants.ts b/apps/namadillo/src/constants.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/namadillo/src/hooks/useIbcTransaction.tsx b/apps/namadillo/src/hooks/useIbcTransaction.tsx index b557d092b..c90ebd3d1 100644 --- a/apps/namadillo/src/hooks/useIbcTransaction.tsx +++ b/apps/namadillo/src/hooks/useIbcTransaction.tsx @@ -1,24 +1,6 @@ -import { useMemo, useState } from "react"; -import { - Address, - AddressWithAssetAndAmount, - ChainRegistryEntry, - GasConfig, - IbcTransferStage, - TransferStep, - TransferTransactionData, -} from "types"; - -type useIbcTransactionProps = { - sourceAddress?: string; - registry?: ChainRegistryEntry; - sourceChannel?: string; - shielded?: boolean; - destinationChannel?: Address; - selectedAsset?: AddressWithAssetAndAmount; -}; - +import { SigningStargateClient } from "@cosmjs/stargate"; import { QueryStatus } from "@tanstack/query-core"; +import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { TokenCurrency } from "App/Common/TokenCurrency"; import { broadcastIbcTransactionAtom, @@ -32,13 +14,35 @@ import { dispatchToastNotificationAtom, } from "atoms/notifications"; import BigNumber from "bignumber.js"; -import { getIbcGasConfig } from "integrations/utils"; import invariant from "invariant"; import { useAtomValue, useSetAtom } from "jotai"; -import { createTransferDataFromIbc } from "lib/transactions"; +import { + createIbcTransferMessage, + createTransferDataFromIbc, +} from "lib/transactions"; +import { useState } from "react"; +import { + Address, + AddressWithAssetAndAmount, + ChainRegistryEntry, + GasConfig, + IbcTransferStage, + TransferStep, + TransferTransactionData, +} from "types"; import { toBaseAmount } from "utils"; import { sanitizeAddress } from "utils/address"; import { getKeplrWallet, sanitizeChannel } from "utils/ibc"; +import { useSimulateIbcTransferFee } from "./useSimulateIbcTransferFee"; + +type useIbcTransactionProps = { + sourceAddress?: string; + registry?: ChainRegistryEntry; + sourceChannel?: string; + shielded?: boolean; + destinationChannel?: Address; + selectedAsset?: AddressWithAssetAndAmount; +}; type useIbcTransactionOutput = { transferToNamada: ( @@ -48,7 +52,7 @@ type useIbcTransactionOutput = { onUpdateStatus?: (status: string) => void ) => Promise; transferStatus: "idle" | QueryStatus; - gasConfig: GasConfig | undefined; + gasConfig: UseQueryResult; }; export const useIbcTransaction = ({ @@ -62,6 +66,37 @@ export const useIbcTransaction = ({ const broadcastIbcTx = useAtomValue(broadcastIbcTransactionAtom); const dispatchNotification = useSetAtom(dispatchToastNotificationAtom); const [txHash, setTxHash] = useState(); + const [rpcUrl, setRpcUrl] = useState(); + const [stargateClient, setStargateClient] = useState< + SigningStargateClient | undefined + >(); + + // Avoid the same client being created twice for the same chain and provide + // a way to refetch the query in case it throws an error trying to connect to the RPC + useQuery({ + queryKey: ["store-stargate-client", registry?.chain.chain_id], + enabled: Boolean(registry?.chain), + queryFn: async () => { + invariant(registry?.chain, "Error: Invalid chain"); + setRpcUrl(undefined); + setStargateClient(undefined); + return await queryAndStoreRpc(registry.chain, async (rpc: string) => { + const client = await createStargateClient(rpc, registry.chain); + setStargateClient(client); + setRpcUrl(rpc); + return client; + }); + }, + }); + + const gasConfigQuery = useSimulateIbcTransferFee({ + stargateClient, + registry, + selectedAsset, + isShieldedTransfer: shielded, + sourceAddress, + channel: sourceChannel, + }); const dispatchPendingTxNotification = (tx: TransferTransactionData): void => { invariant(tx.hash, "Error: Transaction hash not provided"); @@ -90,13 +125,6 @@ export const useIbcTransaction = ({ }); }; - const gasConfig = useMemo(() => { - if (typeof registry !== "undefined") { - return getIbcGasConfig(registry); - } - return undefined; - }, [registry]); - const getIbcTransferStage = (shielded: boolean): IbcTransferStage => { return shielded ? { type: "IbcToShielded", currentStep: TransferStep.IbcToShielded } @@ -116,12 +144,14 @@ export const useIbcTransaction = ({ invariant(selectedAsset, "Error: No asset is selected"); invariant(registry, "Error: Invalid chain"); invariant(sourceChannel, "Error: Invalid IBC source channel"); - invariant(gasConfig, "Error: No transaction fee is set"); + invariant(stargateClient, "Error: Stargate client not initialized"); + invariant(rpcUrl, "Error: RPC URL not initialized"); invariant( !shielded || destinationChannel, "Error: Destination channel not provided" ); + // Set Keplr option to allow Namadillo to set the transaction fee const baseKeplr = getKeplrWallet(); const savedKeplrOptions = baseKeplr.defaultOptions; baseKeplr.defaultOptions = { @@ -132,6 +162,8 @@ export const useIbcTransaction = ({ try { const baseAmount = toBaseAmount(selectedAsset.asset, displayAmount); + + // This step might require a bit of time const { memo: maspCompatibleMemo, receiver: maspCompatibleReceiver } = await (async () => { onUpdateStatus?.("Generating MASP parameters..."); @@ -145,48 +177,49 @@ export const useIbcTransaction = ({ : { memo, receiver: destinationAddress }; })(); - // Set Keplr option to allow Namadillo to set the transaction fee const chainId = registry.chain.chain_id; - - return await queryAndStoreRpc(registry.chain, async (rpc: string) => { - onUpdateStatus?.("Waiting for signature..."); - const client = await createStargateClient(rpc, registry.chain); - const ibcTransferParams = { - signer: baseKeplr.getOfflineSigner(chainId), - chainId, - sourceAddress: sanitizeAddress(sourceAddress), - destinationAddress: sanitizeAddress(maspCompatibleReceiver), - amount: displayAmount, - asset: selectedAsset, - gasConfig, - sourceChannelId: sanitizeChannel(sourceChannel!), - destinationChannelId: sanitizeChannel(destinationChannel!) || "", - isShielded: !!shielded, - }; - - const signedMessage = await getSignedMessage( - client, - ibcTransferParams, - maspCompatibleMemo - ); - - onUpdateStatus?.("Broadcasting transaction..."); - const txResponse = await broadcastIbcTx.mutateAsync({ - client, - tx: signedMessage, - }); - - const tx = createTransferDataFromIbc( - txResponse, - rpc, - selectedAsset.asset, - chainId, - getIbcTransferStage(!!shielded) - ); - dispatchPendingTxNotification(tx); - setTxHash(tx.hash); - return tx; + const transferMsg = createIbcTransferMessage( + sanitizeChannel(sourceChannel!), + sanitizeAddress(sourceAddress), + sanitizeAddress(maspCompatibleReceiver), + baseAmount, + selectedAsset.originalAddress, + maspCompatibleMemo + ); + + // In case the first estimate has failed for some reason, we try to refetch + const gasConfig = await (async () => { + if (!gasConfigQuery.data) { + onUpdateStatus?.("Estimating required gas..."); + return (await gasConfigQuery.refetch()).data; + } + return gasConfigQuery.data; + })(); + invariant(gasConfig, "Error: Failed to estimate gas usage"); + + onUpdateStatus?.("Waiting for signature..."); + const signedMessage = await getSignedMessage( + stargateClient, + transferMsg, + gasConfig + ); + + onUpdateStatus?.("Broadcasting transaction..."); + const txResponse = await broadcastIbcTx.mutateAsync({ + client: stargateClient, + tx: signedMessage, }); + + const tx = createTransferDataFromIbc( + txResponse, + rpcUrl || "", + selectedAsset.asset, + chainId, + getIbcTransferStage(!!shielded) + ); + dispatchPendingTxNotification(tx); + setTxHash(tx.hash); + return tx; } catch (err) { dispatchErrorTxNotification(err); throw err; @@ -197,7 +230,7 @@ export const useIbcTransaction = ({ return { transferToNamada, - gasConfig, + gasConfig: gasConfigQuery, transferStatus: broadcastIbcTx.status, }; }; diff --git a/apps/namadillo/src/hooks/useSimulateIbcTransferFee.ts b/apps/namadillo/src/hooks/useSimulateIbcTransferFee.ts new file mode 100644 index 000000000..026a5418c --- /dev/null +++ b/apps/namadillo/src/hooks/useSimulateIbcTransferFee.ts @@ -0,0 +1,62 @@ +import { SigningStargateClient } from "@cosmjs/stargate"; +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { simulateIbcTransferFee } from "atoms/integrations"; +import BigNumber from "bignumber.js"; +import { getIbcGasConfig } from "integrations/utils"; +import invariant from "invariant"; +import { createIbcTransferMessage } from "lib/transactions"; +import { AddressWithAsset, ChainRegistryEntry, GasConfig } from "types"; +import { sanitizeAddress } from "utils/address"; +import { sanitizeChannel } from "utils/ibc"; + +type useSimulateIbcTransferFeeProps = { + stargateClient?: SigningStargateClient; + registry?: ChainRegistryEntry; + isShieldedTransfer?: boolean; + sourceAddress?: string; + selectedAsset?: AddressWithAsset; + channel?: string; +}; + +export const useSimulateIbcTransferFee = ({ + stargateClient, + registry, + selectedAsset, + isShieldedTransfer, + sourceAddress, + channel, +}: useSimulateIbcTransferFeeProps): UseQueryResult => { + return useQuery({ + queryKey: [ + "gasConfig", + registry?.chain?.chain_id, + selectedAsset?.asset.base, + isShieldedTransfer, + ], + retry: false, + queryFn: async () => { + const MASP_MEMO_LENGTH = 2356; + const transferMsg = createIbcTransferMessage( + sanitizeChannel(channel!), + // We can't mock sourceAddress because the simulate function requires + // a valid address with funds + sanitizeAddress(sourceAddress!), + sanitizeChannel(sourceAddress!), + new BigNumber(1), + selectedAsset?.asset.base || registry?.assets.assets[0].base || "", + isShieldedTransfer ? "0".repeat(MASP_MEMO_LENGTH) : "" + ); + + const estimatedGas = await simulateIbcTransferFee( + stargateClient!, + sourceAddress!, + transferMsg + ); + + const gasConfig = getIbcGasConfig(registry!, estimatedGas); + invariant(gasConfig, "Error: invalid Gas config"); + return gasConfig; + }, + enabled: Boolean(registry && stargateClient && sourceAddress && channel), + }); +}; diff --git a/apps/namadillo/src/hooks/useTransactionNotifications.tsx b/apps/namadillo/src/hooks/useTransactionNotifications.tsx index 6b634ae24..dfff2af11 100644 --- a/apps/namadillo/src/hooks/useTransactionNotifications.tsx +++ b/apps/namadillo/src/hooks/useTransactionNotifications.tsx @@ -429,7 +429,7 @@ export const useTransactionNotifications = (): void => { }); useTransactionEventListener("IbcTransfer.Error", (e) => { - invariant(e.detail.hash, "Notification error: Invalid Tx provider"); + invariant(e.detail.hash, "Notification error: hash was not provided"); const id = createIbcNotificationId(e.detail.hash); clearPendingNotifications(id); diff --git a/apps/namadillo/src/integrations/utils.ts b/apps/namadillo/src/integrations/utils.ts index 63eeb2689..9a5233ee0 100644 --- a/apps/namadillo/src/integrations/utils.ts +++ b/apps/namadillo/src/integrations/utils.ts @@ -40,9 +40,9 @@ export const getAssetImageUrl = (asset?: Asset): string => { }; export const getIbcGasConfig = ( - registry: ChainRegistryEntry + registry: ChainRegistryEntry, + gasLimit: number = 222_000 ): GasConfig | undefined => { - // TODO: we get a better type for registry to avoid optional chaining? // TODO: some chains support multiple fee tokens - what should we do? const feeToken = registry.chain.fees?.fee_tokens?.[0]; if (typeof feeToken !== "undefined") { @@ -52,9 +52,6 @@ export const getIbcGasConfig = ( feeToken.fixed_min_gas_price ?? feeToken.high_gas_price ?? 0; - // TODO to be more precise on gasLimit, we should estimate the transfer gas fee - // `await client.simulate(sourceAddress, [transferMsg], undefined)` - const gasLimit = 222_000; const asset = registry.assets.assets.find( (asset) => asset.base === feeToken.denom diff --git a/apps/namadillo/src/utils/gas.ts b/apps/namadillo/src/utils/gas.ts index 9e3920396..45d9effbc 100644 --- a/apps/namadillo/src/utils/gas.ts +++ b/apps/namadillo/src/utils/gas.ts @@ -8,5 +8,7 @@ export const getBaseGasFee = (gasConfig: GasConfig): BigNumber => { export const getDisplayGasFee = (gasConfig: GasConfig): BigNumber => { const baseFee = getBaseGasFee(gasConfig); - return gasConfig.asset ? toDisplayAmount(gasConfig.asset, baseFee) : baseFee; + return gasConfig.asset ? + toDisplayAmount(gasConfig.asset, baseFee).decimalPlaces(6) + : baseFee; };