diff --git a/apps/namadillo/src/App/Ibc/IbcTransfer.tsx b/apps/namadillo/src/App/Ibc/IbcTransfer.tsx index 76961ceb1..667582578 100644 --- a/apps/namadillo/src/App/Ibc/IbcTransfer.tsx +++ b/apps/namadillo/src/App/Ibc/IbcTransfer.tsx @@ -62,6 +62,7 @@ export const IbcTransfer = (): JSX.Element => { const [generalErrorMessage, setGeneralErrorMessage] = useState(""); const [sourceChannel, setSourceChannel] = useState(""); const [destinationChannel, setDestinationChannel] = useState(""); + const [currentProgress, setCurrentProgress] = useState(); // Derived data const availableAmount = mapUndefined( @@ -115,14 +116,24 @@ export const IbcTransfer = (): JSX.Element => { const onSubmitTransfer = async ({ displayAmount, destinationAddress, + memo, }: OnSubmitTransferParams): Promise => { try { + invariant(selectedAsset?.originalAddress, "Error: Asset not selected"); + invariant(registry?.chain, "Error: Chain not selected"); setGeneralErrorMessage(""); - const result = await transferToNamada(destinationAddress, displayAmount); + setCurrentProgress("Submitting..."); + const result = await transferToNamada( + destinationAddress, + displayAmount, + memo, + setCurrentProgress + ); storeTransaction(result); redirectToTimeline(result); } catch (err) { setGeneralErrorMessage(err + ""); + setCurrentProgress(undefined); } }; @@ -170,7 +181,8 @@ export const IbcTransfer = (): JSX.Element => { onChangeShielded: setShielded, }} gasConfig={gasConfig} - isSubmitting={transferStatus === "pending"} + submittingText={currentProgress} + isSubmitting={transferStatus === "pending" || !!currentProgress} isIbcTransfer={true} requiresIbcChannels={requiresIbcChannels} ibcOptions={{ diff --git a/apps/namadillo/src/App/Transfer/TransferModule.tsx b/apps/namadillo/src/App/Transfer/TransferModule.tsx index c3171ed55..e696796d3 100644 --- a/apps/namadillo/src/App/Transfer/TransferModule.tsx +++ b/apps/namadillo/src/App/Transfer/TransferModule.tsx @@ -68,6 +68,7 @@ export type TransferModuleProps = { destination: TransferDestinationProps; requiresIbcChannels?: boolean; gasConfig?: GasConfig; + submittingText?: string; isSubmitting?: boolean; errorMessage?: string; onSubmitTransfer: (params: OnSubmitTransferParams) => void; @@ -91,6 +92,7 @@ export const TransferModule = ({ source, destination, gasConfig, + submittingText, isSubmitting, isIbcTransfer, ibcOptions, @@ -213,7 +215,7 @@ export const TransferModule = ({ const getButtonText = (): string => { if (isSubmitting) { - return "Submitting..."; + return submittingText || "Submitting..."; } if (validationResult === "NoSourceWallet") { diff --git a/apps/namadillo/src/atoms/integrations/atoms.ts b/apps/namadillo/src/atoms/integrations/atoms.ts index 3d414e062..46dbbff5f 100644 --- a/apps/namadillo/src/atoms/integrations/atoms.ts +++ b/apps/namadillo/src/atoms/integrations/atoms.ts @@ -1,4 +1,5 @@ import { AssetList, Chain } from "@chain-registry/types"; +import { DeliverTxResponse, SigningStargateClient } from "@cosmjs/stargate"; import { ExtensionKey, IbcTransferMsgValue, @@ -8,19 +9,17 @@ import { defaultAccountAtom } from "atoms/accounts"; import { chainAtom, chainParametersAtom } from "atoms/chain"; import { defaultServerConfigAtom, settingsAtom } from "atoms/settings"; import { queryDependentFn } from "atoms/utils"; +import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; import { atom } from "jotai"; import { atomWithMutation, atomWithQuery } from "jotai-tanstack-query"; import { atomFamily, atomWithStorage } from "jotai/utils"; import { TransactionPair } from "lib/query"; -import { createTransferDataFromIbc } from "lib/transactions"; import { AddressWithAssetAndAmountMap, BuildTxAtomParams, ChainId, ChainRegistryEntry, RpcStorage, - TransferStep, - TransferTransactionData, } from "types"; import { addLocalnetToRegistry, @@ -32,16 +31,15 @@ import { mapCoinsToAssets, } from "./functions"; import { + broadcastIbcTransaction, fetchLocalnetTomlConfig, - IbcTransferParams, queryAndStoreRpc, queryAssetBalances, - submitIbcTransfer, } from "./services"; type IBCTransferAtomParams = { - transferParams: IbcTransferParams; - chain: Chain; + client: SigningStargateClient; + tx: TxRaw; }; type AssetBalanceAtomParams = { @@ -69,28 +67,14 @@ export const rpcByChainAtom = atomWithStorage< Record | undefined >("namadillo:rpc:active", undefined); -export const ibcTransferAtom = atomWithMutation(() => { +export const broadcastIbcTransactionAtom = atomWithMutation(() => { return { mutationKey: ["ibc-transfer"], mutationFn: async ({ - transferParams, - chain, - }: IBCTransferAtomParams): Promise => { - return await queryAndStoreRpc(chain, async (rpc: string) => { - const txResponse = await submitIbcTransfer(rpc, transferParams); - return createTransferDataFromIbc( - txResponse, - rpc, - transferParams.asset.asset, - transferParams.chainId, - transferParams.isShielded ? - { type: "IbcToShielded", currentStep: TransferStep.ZkProof } - : { - type: "IbcToTransparent", - currentStep: TransferStep.IbcToTransparent, - } - ); - }); + client, + tx, + }: IBCTransferAtomParams): Promise => { + return await broadcastIbcTransaction(client, tx); }, }; }); diff --git a/apps/namadillo/src/atoms/integrations/services.ts b/apps/namadillo/src/atoms/integrations/services.ts index 31676f2e5..ac11a042a 100644 --- a/apps/namadillo/src/atoms/integrations/services.ts +++ b/apps/namadillo/src/atoms/integrations/services.ts @@ -8,6 +8,8 @@ import { assertIsDeliverTxSuccess, calculateFee, } from "@cosmjs/stargate"; +import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; + import { getIndexerApi } from "atoms/api"; import { queryForAck, queryForIbcTimeout } from "atoms/transactions"; import BigNumber from "bignumber.js"; @@ -23,6 +25,7 @@ import { TransferStep, } from "types"; import { toBaseAmount } from "utils"; +import { getKeplrWallet } from "utils/ibc"; import { getSdkInstance } from "utils/sdk"; import { rpcByChainAtom } from "./atoms"; import { getRpcByIndex } from "./functions"; @@ -46,7 +49,7 @@ type ShieldedParams = CommonParams & { export type IbcTransferParams = TransparentParams | ShieldedParams; -const getShieldedArgs = async ( +export const getShieldedArgs = async ( target: string, token: string, amount: BigNumber, @@ -82,26 +85,31 @@ export const queryAssetBalances = async ( })); }; -export const submitIbcTransfer = async ( +export const createStargateClient = async ( rpc: string, - transferParams: IbcTransferParams -): Promise => { + chain: Chain +): Promise => { + const keplr = getKeplrWallet(); + const signer = keplr.getOfflineSigner(chain.chain_id); + return await SigningStargateClient.connectWithSigner(rpc, signer, { + broadcastPollIntervalMs: 300, + broadcastTimeoutMs: 8_000, + }); +}; + +export const getSignedMessage = async ( + client: SigningStargateClient, + transferParams: IbcTransferParams, + maspCompatibleMemo: string = "" +): Promise => { const { - signer, sourceAddress, - destinationAddress, amount: displayAmount, asset, sourceChannelId, - isShielded, gasConfig, } = transferParams; - const client = await SigningStargateClient.connectWithSigner(rpc, signer, { - broadcastPollIntervalMs: 300, - broadcastTimeoutMs: 8_000, - }); - // cosmjs expects amounts to be represented in the base denom, so convert const baseAmount = toBaseAmount(asset.asset, displayAmount); @@ -110,32 +118,24 @@ export const submitIbcTransfer = async ( `${gasConfig.gasPrice.toString()}${gasConfig.gasToken}` ); - const token = asset.originalAddress; - const { receiver, memo }: { receiver: string; memo?: string } = - isShielded ? - await getShieldedArgs( - destinationAddress, - token, - baseAmount, - transferParams.destinationChannelId - ) - : { receiver: destinationAddress }; - const transferMsg = createIbcTransferMessage( sourceChannelId, sourceAddress, - receiver, + transferParams.destinationAddress, baseAmount, asset.originalAddress, - memo + maspCompatibleMemo ); - const response = await client.signAndBroadcast( - sourceAddress, - [transferMsg], - fee - ); + return await client.sign(sourceAddress, [transferMsg], fee, ""); +}; +export const broadcastIbcTransaction = async ( + client: SigningStargateClient, + tx: TxRaw +): Promise => { + const txBytes = TxRaw.encode(tx).finish(); + const response = await client.broadcastTx(txBytes); assertIsDeliverTxSuccess(response); return response; }; diff --git a/apps/namadillo/src/hooks/useIbcTransaction.tsx b/apps/namadillo/src/hooks/useIbcTransaction.tsx index e3b2c8bac..b557d092b 100644 --- a/apps/namadillo/src/hooks/useIbcTransaction.tsx +++ b/apps/namadillo/src/hooks/useIbcTransaction.tsx @@ -4,6 +4,8 @@ import { AddressWithAssetAndAmount, ChainRegistryEntry, GasConfig, + IbcTransferStage, + TransferStep, TransferTransactionData, } from "types"; @@ -16,10 +18,15 @@ type useIbcTransactionProps = { selectedAsset?: AddressWithAssetAndAmount; }; -import { Keplr, Window as KeplrWindow } from "@keplr-wallet/types"; import { QueryStatus } from "@tanstack/query-core"; import { TokenCurrency } from "App/Common/TokenCurrency"; -import { ibcTransferAtom } from "atoms/integrations"; +import { + broadcastIbcTransactionAtom, + createStargateClient, + getShieldedArgs, + getSignedMessage, + queryAndStoreRpc, +} from "atoms/integrations"; import { createIbcNotificationId, dispatchToastNotificationAtom, @@ -28,11 +35,17 @@ 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 { toBaseAmount } from "utils"; +import { sanitizeAddress } from "utils/address"; +import { getKeplrWallet, sanitizeChannel } from "utils/ibc"; type useIbcTransactionOutput = { transferToNamada: ( destinationAddress: string, - displayAmount: BigNumber + displayAmount: BigNumber, + memo?: string, + onUpdateStatus?: (status: string) => void ) => Promise; transferStatus: "idle" | QueryStatus; gasConfig: GasConfig | undefined; @@ -46,7 +59,7 @@ export const useIbcTransaction = ({ shielded, destinationChannel, }: useIbcTransactionProps): useIbcTransactionOutput => { - const performIbcTransfer = useAtomValue(ibcTransferAtom); + const broadcastIbcTx = useAtomValue(broadcastIbcTransactionAtom); const dispatchNotification = useSetAtom(dispatchToastNotificationAtom); const [txHash, setTxHash] = useState(); @@ -84,34 +97,33 @@ export const useIbcTransaction = ({ return undefined; }, [registry]); - const getWallet = (): Keplr => { - const wallet = (window as KeplrWindow).keplr; - if (typeof wallet === "undefined") { - throw new Error("No Keplr instance"); - } - return wallet; + const getIbcTransferStage = (shielded: boolean): IbcTransferStage => { + return shielded ? + { type: "IbcToShielded", currentStep: TransferStep.IbcToShielded } + : { + type: "IbcToTransparent", + currentStep: TransferStep.IbcToTransparent, + }; }; const transferToNamada = async ( destinationAddress: Address, - displayAmount: BigNumber + displayAmount: BigNumber, + memo: string = "", + onUpdateStatus?: (status: string) => void ): Promise => { - invariant(sourceAddress, "Source address is not defined"); - invariant(selectedAsset, "No asset is selected"); - invariant(registry, "Invalid chain"); - invariant(sourceChannel, "Invalid IBC source channel"); + invariant(sourceAddress, "Error: Source address is not defined"); + 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( !shielded || destinationChannel, - "Invalid IBC destination channel" + "Error: Destination channel not provided" ); - invariant(gasConfig, "No transaction fee is set"); - // Set Keplr option to allow Namadillo to set the transaction fee - const baseKeplr = getWallet(); - const chainId = registry!.chain.chain_id; + const baseKeplr = getKeplrWallet(); const savedKeplrOptions = baseKeplr.defaultOptions; - let tx: TransferTransactionData; - baseKeplr.defaultOptions = { sign: { preferNoSetFee: true, @@ -119,42 +131,73 @@ export const useIbcTransaction = ({ }; try { - tx = await performIbcTransfer.mutateAsync({ - chain: registry!.chain, - transferParams: { + const baseAmount = toBaseAmount(selectedAsset.asset, displayAmount); + const { memo: maspCompatibleMemo, receiver: maspCompatibleReceiver } = + await (async () => { + onUpdateStatus?.("Generating MASP parameters..."); + return shielded ? + await getShieldedArgs( + destinationAddress, + selectedAsset.originalAddress, + baseAmount, + destinationChannel! + ) + : { 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, - destinationAddress, + sourceAddress: sanitizeAddress(sourceAddress), + destinationAddress: sanitizeAddress(maspCompatibleReceiver), amount: displayAmount, asset: selectedAsset, gasConfig, - sourceChannelId: sourceChannel!.trim(), - ...(shielded ? - { - isShielded: true, - destinationChannelId: destinationChannel!.trim(), - } - : { - isShielded: false, - }), - }, + 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; }); - dispatchPendingTxNotification(tx); - setTxHash(tx.hash); } catch (err) { dispatchErrorTxNotification(err); throw err; } finally { baseKeplr.defaultOptions = savedKeplrOptions; } - - return tx; }; return { transferToNamada, gasConfig, - transferStatus: performIbcTransfer.status, + transferStatus: broadcastIbcTx.status, }; }; diff --git a/apps/namadillo/src/utils/address.ts b/apps/namadillo/src/utils/address.ts new file mode 100644 index 000000000..870f7a8d8 --- /dev/null +++ b/apps/namadillo/src/utils/address.ts @@ -0,0 +1,4 @@ +import { Address } from "types"; + +export const sanitizeAddress = (address: Address): Address => + address.toLowerCase().trim(); diff --git a/apps/namadillo/src/utils/ibc.ts b/apps/namadillo/src/utils/ibc.ts new file mode 100644 index 000000000..3b89aaeb3 --- /dev/null +++ b/apps/namadillo/src/utils/ibc.ts @@ -0,0 +1,14 @@ +import { Keplr, Window as KeplrWindow } from "@keplr-wallet/types"; + +export const getKeplrWallet = (): Keplr => { + const wallet = (window as KeplrWindow).keplr; + if (typeof wallet === "undefined") { + throw new Error("No Keplr instance"); + } + return wallet; +}; + +export const sanitizeChannel = (channel: string): string => { + const numericValue = channel.replace(/[^0-9]/g, ""); + return `channel-${numericValue}`; +};