diff --git a/apps/namadillo/src/App/AccountOverview/UnshieldedAssetTable.tsx b/apps/namadillo/src/App/AccountOverview/UnshieldedAssetTable.tsx index 29416f9e4a..e8420fa720 100644 --- a/apps/namadillo/src/App/AccountOverview/UnshieldedAssetTable.tsx +++ b/apps/namadillo/src/App/AccountOverview/UnshieldedAssetTable.tsx @@ -17,7 +17,6 @@ import { useBalances } from "hooks/useBalances"; import { useAtomValue } from "jotai"; import { useEffect, useMemo, useState } from "react"; import { IoSwapHorizontal } from "react-icons/io5"; -import { TbVectorTriangle } from "react-icons/tb"; import { Link } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { TokenBalance } from "types"; @@ -128,12 +127,6 @@ const TransparentTokensTable = ({ url: `${routes.transfer}?${params.asset}=${address}&${params.shielded}=0`, icon: , }, - { - url: `${routes.ibcWithdraw}?${params.asset}=${address}`, - icon: ( - - ), - }, ].map(({ url, icon }) => (
{ if (aIsNam !== bIsNam) return aIsNam ? -1 : 1; const aValue = BigNumber(a.amount); const bValue = BigNumber(b.amount); - return bValue.comparedTo(aValue); + return bValue.comparedTo(aValue) || 0; }); }; diff --git a/apps/namadillo/src/App/AppRoutes.tsx b/apps/namadillo/src/App/AppRoutes.tsx index c0ace54e78..acef34a7aa 100644 --- a/apps/namadillo/src/App/AppRoutes.tsx +++ b/apps/namadillo/src/App/AppRoutes.tsx @@ -21,13 +21,6 @@ import { SubmitVote } from "./Governance/SubmitVote"; import { ViewJson } from "./Governance/ViewJson"; import { IbcLayout } from "./Ibc/IbcLayout"; import { IbcShieldAll } from "./Ibc/IbcShieldAll"; -import { IbcTransfer } from "./Ibc/IbcTransfer"; -import { IbcTransfersLayout } from "./Ibc/IbcTransfersLayout"; -import { IbcWithdraw } from "./Ibc/IbcWithdraw"; -import { MaspLayout } from "./Masp/MaspLayout"; -import { MaspShield } from "./Masp/MaspShield"; -import { MaspUnshield } from "./Masp/MaspUnshield"; -import { NamadaTransfer } from "./NamadaTransfer/NamadaTransfer"; import { routes } from "./routes"; import { Advanced } from "./Settings/Advanced"; import { EnableFeatures } from "./Settings/EnableFeatures"; @@ -46,6 +39,7 @@ import { Unstake } from "./Staking/Unstake"; import { SwitchAccountPanel } from "./SwitchAccount/SwitchAccountPanel"; import { TransactionDetails } from "./Transactions/TransactionDetails"; import { TransactionHistory } from "./Transactions/TransactionHistory"; +import { ReceiveCard } from "./Transfer"; import { TransferLayout } from "./Transfer/TransferLayout"; export const MainRoutes = (): JSX.Element => { @@ -69,7 +63,6 @@ export const MainRoutes = (): JSX.Element => { > {/* Home */} } /> - {/* Staking */} } /> { path={routes.stakingBondingRedelegate} element={} /> - + {/* Receive */} + } /> {/* Governance */} } /> { } /> } /> - {/* Masp */} - {features.maspEnabled && ( - }> - } /> - } /> - - )} - - {/* Ibc Transfers */} - {features.ibcTransfersEnabled && ( - }> - } /> - } /> - - )} - {features.ibcTransfersEnabled && ( }> } /> )} - - {/* Transfer */} - {(features.maspEnabled || features.namTransfersEnabled) && ( - }> - } /> - - )} + {/* Transfer - All transfer types now handled by TransferLayout */} + }> + } /> + } /> + } /> + } /> + } /> + {/* Transaction History */} {(features.namTransfersEnabled || features.ibcTransfersEnabled) && ( @@ -130,7 +109,6 @@ export const MainRoutes = (): JSX.Element => { /> )} - {/* Other */} } /> } /> diff --git a/apps/namadillo/src/App/Common/SelectModal.tsx b/apps/namadillo/src/App/Common/SelectModal.tsx index 47f40bcc44..a453f784f5 100644 --- a/apps/namadillo/src/App/Common/SelectModal.tsx +++ b/apps/namadillo/src/App/Common/SelectModal.tsx @@ -31,7 +31,7 @@ export const SelectModal = ({ )} {...props} > -
+
{title}
- {children} +
{children}
diff --git a/apps/namadillo/src/App/Common/SidebarMenuItem.tsx b/apps/namadillo/src/App/Common/SidebarMenuItem.tsx index aa080184f7..a83c233e5c 100644 --- a/apps/namadillo/src/App/Common/SidebarMenuItem.tsx +++ b/apps/namadillo/src/App/Common/SidebarMenuItem.tsx @@ -1,12 +1,21 @@ import clsx from "clsx"; -import { NavLink } from "react-router-dom"; +import { NavLink, useLocation } from "react-router-dom"; type Props = { url?: string; children: React.ReactNode; + shouldHighlight?: boolean; + preventNavigationOnSameRoute?: boolean; }; -export const SidebarMenuItem = ({ url, children }: Props): JSX.Element => { +export const SidebarMenuItem = ({ + url, + children, + shouldHighlight, + preventNavigationOnSameRoute = false, +}: Props): JSX.Element => { + const location = useLocation(); + const className = clsx( "flex items-center gap-5 text-lg text-white", "transition-colors duration-300 ease-out-quad hover:text-cyan", @@ -19,12 +28,19 @@ export const SidebarMenuItem = ({ url, children }: Props): JSX.Element => { return {children}; } + const handleClick = (e: React.MouseEvent): void => { + if (preventNavigationOnSameRoute && location.pathname === url) { + e.preventDefault(); + } + }; + return ( clsx(className, { - "text-yellow font-bold": isActive, + "text-yellow font-bold": isActive || shouldHighlight, }) } > diff --git a/apps/namadillo/src/App/Common/TransactionFee.tsx b/apps/namadillo/src/App/Common/TransactionFee.tsx index 868d7bcfa8..58983e3417 100644 --- a/apps/namadillo/src/App/Common/TransactionFee.tsx +++ b/apps/namadillo/src/App/Common/TransactionFee.tsx @@ -11,8 +11,10 @@ export const TransactionFee = ({ symbol, }: TransactionFeeProps): JSX.Element => { return ( -
- Fee: +
+ + Transaction Fee +
-
Fee Options:
+
Fees:
diff --git a/apps/namadillo/src/App/Common/UnshieldAssetsModal.tsx b/apps/namadillo/src/App/Common/UnshieldAssetsModal.tsx index 6cd521f7e2..5bc3c6d6ae 100644 --- a/apps/namadillo/src/App/Common/UnshieldAssetsModal.tsx +++ b/apps/namadillo/src/App/Common/UnshieldAssetsModal.tsx @@ -39,7 +39,7 @@ export const UnshieldAssetsModal = ({ ), - onClick: () => goTo(routes.unshield), + onClick: () => goTo(routes.maspUnshield), children: "Unshield assets from your Namada shielded to transparent account", }, diff --git a/apps/namadillo/src/App/Ibc/IbcShieldAll.tsx b/apps/namadillo/src/App/Ibc/IbcShieldAll.tsx index c5ce10bb61..c45fa300b7 100644 --- a/apps/namadillo/src/App/Ibc/IbcShieldAll.tsx +++ b/apps/namadillo/src/App/Ibc/IbcShieldAll.tsx @@ -2,7 +2,6 @@ import { Chain } from "@chain-registry/types"; import { Panel } from "@namada/components"; import { useAssetAmount } from "hooks/useAssetAmount"; import { useWalletManager } from "hooks/useWalletManager"; -import { wallets } from "integrations"; import { KeplrWalletManager } from "integrations/Keplr"; import { useState } from "react"; import { Asset } from "types"; @@ -41,7 +40,6 @@ export const IbcShieldAll: React.FC = () => { {connected && !isSuccess && ( { - const navigate = useNavigate(); +interface IbcTransferProps { + sourceAddress: string | undefined; + setSourceAddress: (address: string | undefined) => void; + destinationAddress: string | undefined; + setDestinationAddress: (address: string | undefined) => void; + keplrWalletManager: KeplrWalletManager; + assetSelectorModalOpen?: boolean; + setAssetSelectorModalOpen?: (open: boolean) => void; +} + +export const IbcTransfer = ({ + sourceAddress, + setSourceAddress, + destinationAddress, + setDestinationAddress, + keplrWalletManager, + assetSelectorModalOpen, + setAssetSelectorModalOpen, +}: IbcTransferProps): JSX.Element => { + // COMPONENT STATE const [completedAt, setCompletedAt] = useState(); - - const availableChains = useMemo(getAvailableChains, []); - - // Global & Atom states + const [sourceChannel, setSourceChannel] = useState(""); + const [destinationChannel, setDestinationChannel] = useState(""); + const [selectedAssetWithAmount, setSelectedAssetWithAmount] = useState< + AssetWithAmount | undefined + >(); + const [amount, setAmount] = useState(); + const [txHash, setTxHash] = useState(); + // ERROR & STATUS STATE + const [generalErrorMessage, setGeneralErrorMessage] = useState(""); + const [currentStatus, setCurrentStatus] = useState(); + // GLOBAL STATE + const navigate = useNavigate(); const defaultAccounts = useAtomValue(allDefaultAccountsAtom); - - // Wallet & Registry - const { - registry, - walletAddress: sourceAddress, - connectToChainId, - } = useWalletManager(keplr); - const namadaChainRegistry = useAtomValue(namadaChainRegistryAtom); - const chainRegistry = namadaChainRegistry.data; - - // IBC Channels & Balances + const { registry } = useWalletManager(keplrWalletManager); const { data: ibcChannels, isError: unknownIbcChannels, isLoading: isLoadingIbcChannels, } = useAtomValue(ibcChannelsFamily(registry?.chain.chain_name)); - - const { data: userAssets, isLoading: isLoadingBalances } = useAtomValue( + const { data: userAssets } = useAtomValue( assetBalanceAtomFamily({ chain: registry?.chain, walletAddress: sourceAddress, }) ); - const { trackEvent } = useFathomTracker(); - const [shielded, setShielded] = useState(true); - const [selectedAssetBase, setSelectedAssetBase] = useUrlState(params.asset); - const [amount, setAmount] = useState(); - const [generalErrorMessage, setGeneralErrorMessage] = useState(""); - const [sourceChannel, setSourceChannel] = useState(""); - const [destinationChannel, setDestinationChannel] = useState(""); - const [currentProgress, setCurrentProgress] = useState(); - const [txHash, setTxHash] = useState(); - - const availableDisplayAmount = mapUndefined((baseDenom) => { - return userAssets ? userAssets[baseDenom]?.amount : undefined; - }, selectedAssetBase); - - const selectedAsset = - selectedAssetBase ? userAssets?.[selectedAssetBase]?.asset : undefined; - - const availableAssets = useMemo(() => { - if (!userAssets || !registry) return undefined; - - const output: Record = {}; - - Object.entries(userAssets).forEach(([key, { asset }]) => { - if ( - SUPPORTED_ASSETS_MAP.get(registry.chain.chain_name)?.includes( - asset.symbol - ) - ) { - output[key] = { ...userAssets[key] }; - } - }); - - return output; - }, [Object.keys(userAssets || {}).join(""), registry?.chain.chain_name]); - - // Manage the history of transactions const { storeTransaction } = useTransactionActions(); - // Utils for IBC transfers const { transferToNamada, gasConfig } = useIbcTransaction({ registry, sourceAddress, sourceChannel, destinationChannel, - shielded, - selectedAsset, + shielded: isShieldedAddress(destinationAddress ?? ""), + selectedAsset: selectedAssetWithAmount?.asset, }); + // DERIVED VALUES + const shielded = isShieldedAddress(destinationAddress ?? ""); + const availableDisplayAmount = mapUndefined((baseDenom) => { + return userAssets ? userAssets[baseDenom]?.amount : undefined; + }, selectedAssetWithAmount?.asset?.address); const namadaAddress = useMemo(() => { return ( defaultAccounts.data?.find( @@ -121,15 +90,12 @@ export const IbcTransfer = (): JSX.Element => { )?.address || "" ); }, [defaultAccounts, shielded]); - const requiresIbcChannels = Boolean( !isLoadingIbcChannels && (unknownIbcChannels || (shielded && ibcChannels && !ibcChannels?.namadaChannel)) ); - useEffect(() => setSelectedAssetBase(undefined), [registry]); - // Set source and destination channels based on IBC channels data useEffect(() => { setSourceChannel(ibcChannels?.ibcChannel || ""); @@ -159,12 +125,12 @@ export const IbcTransfer = (): JSX.Element => { try { invariant(registry?.chain, "Error: Chain not selected"); setGeneralErrorMessage(""); - setCurrentProgress("Submitting..."); + setCurrentStatus("Submitting..."); const result = await transferToNamada.mutateAsync({ - destinationAddress, - displayAmount, + destinationAddress: destinationAddress ?? "", + displayAmount: new BigNumber(displayAmount ?? "0"), memo, - onUpdateStatus: setCurrentProgress, + onUpdateStatus: setCurrentStatus, }); storeTransaction(result); setTxHash(result.hash); @@ -173,20 +139,8 @@ export const IbcTransfer = (): JSX.Element => { ); } catch (err) { setGeneralErrorMessage(err + ""); - setCurrentProgress(undefined); - } - }; - - const onChangeWallet = (): void => { - if (registry) { - connectToChainId(registry.chain.chain_id); - return; + setCurrentStatus(undefined); } - connectToChainId(defaultChainId); - }; - - const onChangeChain = (chain: Chain): void => { - connectToChainId(chain.chain_id); }; return ( @@ -194,34 +148,22 @@ export const IbcTransfer = (): JSX.Element => {
-
{!completedAt && }
{ transferToNamada.isSuccess } completedAt={completedAt} - ibcTransfer={"deposit"} - currentStatus={currentProgress} + currentStatus={currentStatus ?? ""} requiresIbcChannels={requiresIbcChannels} - ibcOptions={{ + ibcChannels={{ sourceChannel, - onChangeSourceChannel: setSourceChannel, destinationChannel, + onChangeSourceChannel: setSourceChannel, onChangeDestinationChannel: setDestinationChannel, }} errorMessage={generalErrorMessage || transferToNamada.error?.message} @@ -245,6 +186,9 @@ export const IbcTransfer = (): JSX.Element => { txHash && navigate(generatePath(routes.transaction, { hash: txHash })); }} + keplrWalletManager={keplrWalletManager} + assetSelectorModalOpen={assetSelectorModalOpen} + setAssetSelectorModalOpen={setAssetSelectorModalOpen} />
); diff --git a/apps/namadillo/src/App/Ibc/IbcTransfersLayout.tsx b/apps/namadillo/src/App/Ibc/IbcTransfersLayout.tsx deleted file mode 100644 index b802168b04..0000000000 --- a/apps/namadillo/src/App/Ibc/IbcTransfersLayout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Panel } from "@namada/components"; -import { NavigationFooter } from "App/AccountOverview/NavigationFooter"; -import { ConnectPanel } from "App/Common/ConnectPanel"; -import { PageWithSidebar } from "App/Common/PageWithSidebar"; -import { Sidebar } from "App/Layout/Sidebar"; -import { useUserHasAccount } from "hooks/useIsAuthenticated"; -import { Outlet } from "react-router-dom"; -import { LearnAboutIbc } from "./LearnAboutIbc"; - -export const IbcTransfersLayout = (): JSX.Element => { - const userHasAccount = useUserHasAccount(); - - if (!userHasAccount) { - return ; - } - - const renderOutletContent = (): JSX.Element => ( - - - - ); - - return ( - -
- {renderOutletContent()} - -
- - - -
- ); -}; diff --git a/apps/namadillo/src/App/Ibc/IbcWithdraw.tsx b/apps/namadillo/src/App/Ibc/IbcWithdraw.tsx index c32bbbbf0c..2642580b8d 100644 --- a/apps/namadillo/src/App/Ibc/IbcWithdraw.tsx +++ b/apps/namadillo/src/App/Ibc/IbcWithdraw.tsx @@ -1,12 +1,10 @@ -import { Chain } from "@chain-registry/types"; import { IbcTransferProps } from "@namada/sdk-multicore"; import { AccountType } from "@namada/types"; import { mapUndefined } from "@namada/utils"; -import { params, routes } from "App/routes"; -import { - OnSubmitTransferParams, - TransferModule, -} from "App/Transfer/TransferModule"; +import { routes } from "App/routes"; +import { isShieldedAddress } from "App/Transfer/common"; +import { TransferModule } from "App/Transfer/TransferModule"; +import { OnSubmitTransferParams } from "App/Transfer/types"; import { allDefaultAccountsAtom, defaultAccountAtom, @@ -17,11 +15,7 @@ import { namadaTransparentAssetsAtom, } from "atoms/balance"; import { chainAtom } from "atoms/chain"; -import { - getChainRegistryByChainName, - ibcChannelsFamily, - namadaChainRegistryAtom, -} from "atoms/integrations"; +import { ibcChannelsFamily } from "atoms/integrations"; import { ledgerStatusDataAtom } from "atoms/ledger"; import { createIbcTxAtom } from "atoms/transfer/atoms"; import { @@ -29,98 +23,97 @@ import { persistDisposableSigner, } from "atoms/transfer/services"; import BigNumber from "bignumber.js"; -import * as osmosis from "chain-registry/mainnet/osmosis"; import { useFathomTracker } from "hooks/useFathomTracker"; -import { useRequiresNewShieldedSync } from "hooks/useRequiresNewShieldedSync"; import { useTransaction } from "hooks/useTransaction"; import { useTransactionActions } from "hooks/useTransactionActions"; -import { useUrlState } from "hooks/useUrlState"; import { useWalletManager } from "hooks/useWalletManager"; -import { wallets } from "integrations"; import { KeplrWalletManager } from "integrations/Keplr"; import invariant from "invariant"; import { useAtom, useAtomValue } from "jotai"; import { TransactionPair } from "lib/query"; import { useEffect, useState } from "react"; import { generatePath, useNavigate } from "react-router-dom"; -import { Asset, IbcTransferTransactionData, TransferStep } from "types"; import { - isNamadaAsset, + Asset, + AssetWithAmount, + IbcTransferTransactionData, + TransferStep, +} from "types"; +import { toBaseAmount, toDisplayAmount, useTransactionEventListener, } from "utils"; -import { IbcTabNavigation } from "./IbcTabNavigation"; import { IbcTopHeader } from "./IbcTopHeader"; -const defaultChainId = "cosmoshub-4"; -const keplr = new KeplrWalletManager(); - -export const IbcWithdraw = (): JSX.Element => { - const defaultAccounts = useAtomValue(allDefaultAccountsAtom); - const shieldedAccount = defaultAccounts.data?.find( - (account) => account.type === AccountType.ShieldedKeys - ); - const transparentAccount = useAtomValue(defaultAccountAtom); - const namadaChain = useAtomValue(chainAtom); - const [ledgerStatus, setLedgerStatusStop] = useAtom(ledgerStatusDataAtom); - const namadaChainRegistry = useAtomValue(namadaChainRegistryAtom); - const chain = namadaChainRegistry.data?.chain; - - const requiresNewShieldedSync = useRequiresNewShieldedSync(); - const [generalErrorMessage, setGeneralErrorMessage] = useState(""); - const [selectedAssetAddress, setSelectedAssetAddress] = useUrlState( - params.asset - ); - const [shielded, setShielded] = useState(!requiresNewShieldedSync); +interface IbcWithdrawProps { + sourceAddress: string | undefined; + setSourceAddress: (address: string | undefined) => void; + destinationAddress: string | undefined; + setDestinationAddress: (address: string | undefined) => void; + keplrWalletManager: KeplrWalletManager; + assetSelectorModalOpen?: boolean; + setAssetSelectorModalOpen?: (open: boolean) => void; +} + +export const IbcWithdraw = ({ + sourceAddress, + setSourceAddress, + destinationAddress, + setDestinationAddress, + keplrWalletManager, + assetSelectorModalOpen, + setAssetSelectorModalOpen, +}: IbcWithdrawProps): JSX.Element => { + // COMPONENT STATE + const [selectedAssetWithAmount, setSelectedAssetWithAmount] = useState< + AssetWithAmount | undefined + >(); const [refundTarget, setRefundTarget] = useState(); const [amount, setAmount] = useState(); const [customAddress, setCustomAddress] = useState(""); const [sourceChannel, setSourceChannel] = useState(""); - const [currentStatus, setCurrentStatus] = useState(""); - const [statusExplanation, setStatusExplanation] = useState(""); const [completedAt, setCompletedAt] = useState(); const [txHash, setTxHash] = useState(); - const [destinationChain, setDestinationChain] = useState(); + // ERROR & STATUS STATE + const [currentStatus, setCurrentStatus] = useState(""); + const [statusExplanation, setStatusExplanation] = useState(""); + const [generalErrorMessage, setGeneralErrorMessage] = useState(""); + // GLOBAL STATE + const defaultAccounts = useAtomValue(allDefaultAccountsAtom); + const { + walletAddress: keplrAddress, + chainId, + registry, + } = useWalletManager(keplrWalletManager); + const transparentAccount = useAtomValue(defaultAccountAtom); + const namadaChain = useAtomValue(chainAtom); + const [ledgerStatus, setLedgerStatusStop] = useAtom(ledgerStatusDataAtom); const { refetch: genDisposableSigner } = useAtomValue(disposableSignerAtom); - const alias = shieldedAccount?.alias ?? transparentAccount.data?.alias; - - const { data: availableAssets, isLoading: isLoadingAssets } = useAtomValue( - shielded ? namadaShieldedAssetsAtom : namadaTransparentAssetsAtom - ); - const { storeTransaction } = useTransactionActions(); const { trackEvent } = useFathomTracker(); const navigate = useNavigate(); - + // DERIVED VALUES + const shieldedAccount = defaultAccounts.data?.find( + (account) => account.type === AccountType.ShieldedKeys + ); + const alias = shieldedAccount?.alias ?? transparentAccount.data?.alias; + const shielded = isShieldedAddress(sourceAddress ?? ""); + const { data: availableAssets } = useAtomValue( + shielded ? namadaShieldedAssetsAtom : namadaTransparentAssetsAtom + ); const ledgerAccountInfo = ledgerStatus && { deviceConnected: ledgerStatus.connected, errorMessage: ledgerStatus.errorMessage, }; - const availableAmount = mapUndefined( (address) => availableAssets?.[address]?.amount, - selectedAssetAddress + selectedAssetWithAmount?.asset.address ); - const selectedAsset = - selectedAssetAddress ? availableAssets?.[selectedAssetAddress] : undefined; - - const { - walletAddress: keplrAddress, - connectToChainId, - chainId, - registry, - loadWalletAddress, - } = useWalletManager(keplr); - - const onChangeWallet = (): void => { - if (registry) { - connectToChainId(registry.chain.chain_id); - return; - } - connectToChainId(defaultChainId); - }; + selectedAssetWithAmount?.asset.address ? + availableAssets?.[selectedAssetWithAmount?.asset.address] + : undefined; useTransactionEventListener( ["IbcWithdraw.Success", "ShieldedIbcWithdraw.Success"], @@ -149,58 +142,16 @@ export const IbcWithdraw = (): JSX.Element => { } }; - const updateDestinationChainAndAddress = async ( - chain: Chain | undefined - ): Promise => { - setDestinationChain(chain); - if (customAddress) { - setCustomAddress(""); - } - if (chain) { - await connectToChainId(chain.chain_id); - await loadWalletAddress(chain.chain_id); - } - }; - const { data: ibcChannels, isError: unknownIbcChannels, isLoading: isLoadingIbcChannels, - } = useAtomValue(ibcChannelsFamily(destinationChain?.chain_name)); + } = useAtomValue(ibcChannelsFamily(registry?.chain.chain_name)); useEffect(() => { setSourceChannel(ibcChannels?.namadaChannel || ""); }, [ibcChannels]); - // Search for original chain. We don't want to enable users to transfer Namada assets - // to other chains different than the original one. Ex: OSMO should only be withdrew to Osmosis, - // ATOM to Cosmoshub, etc. - useEffect(() => { - (async () => { - if (!selectedAsset) { - await updateDestinationChainAndAddress(undefined); - return; - } - - let chain: Chain | undefined; - - if (isNamadaAsset(selectedAsset.asset)) { - chain = osmosis.chain; // for now, NAM uses the osmosis chain - } else if (selectedAsset.asset.traces) { - const trace = selectedAsset.asset.traces.find( - (trace) => trace.type === "ibc" - ); - - if (trace) { - const chainName = trace.counterparty.chain_name; - chain = getChainRegistryByChainName(chainName)?.chain; - } - } - - await updateDestinationChainAndAddress(chain); - })(); - }, [selectedAsset]); - const { execute: performWithdraw, feeProps, @@ -326,7 +277,10 @@ export const IbcWithdraw = (): JSX.Element => { invariant(shieldedAccount, "No shielded account is found"); invariant(transparentAccount.data, "No transparent account is found"); - const amountInBaseDenom = toBaseAmount(selectedAsset.asset, displayAmount); + const amountInBaseDenom = toBaseAmount( + selectedAsset.asset, + BigNumber(displayAmount ?? 0) + ); const source = shielded ? shieldedAccount.pseudoExtendedKey! @@ -351,7 +305,7 @@ export const IbcWithdraw = (): JSX.Element => { portId: "transfer", token: selectedAsset.asset.address, source, - receiver: destinationAddress, + receiver: destinationAddress ?? "", gasSpendingKey, memo, refundTarget, @@ -370,45 +324,24 @@ export const IbcWithdraw = (): JSX.Element => {
-
{!completedAt && }
{ - if (requiresNewShieldedSync) { - setShielded(false); - } else { - setShielded(isShielded); - } - }, + selectedAssetWithAmount, amount, - onChangeAmount: setAmount, ledgerAccountInfo, + onChangeAddress: setSourceAddress, + onChangeSelectedAsset: setSelectedAssetWithAmount, + onChangeAmount: setAmount, }} destination={{ - wallet: wallets.keplr, - walletAddress: keplrAddress, - availableWallets: [wallets.keplr], - enableCustomAddress: true, customAddress, - onChangeCustomAddress: setCustomAddress, - chain: destinationChain, - onChangeWallet, + address: destinationAddress, + onChangeAddress: + customAddress ? setCustomAddress : setDestinationAddress, isShieldedAddress: false, }} - isShieldedTx={shielded} errorMessage={generalErrorMessage || error?.message || ""} currentStatus={currentStatus} currentStatusExplanation={statusExplanation} @@ -418,9 +351,8 @@ export const IbcWithdraw = (): JSX.Element => { * from the confirmation event from target chain */ isSuccess } - ibcTransfer={"withdraw"} requiresIbcChannels={requiresIbcChannels} - ibcOptions={{ + ibcChannels={{ sourceChannel, onChangeSourceChannel: setSourceChannel, }} @@ -428,7 +360,9 @@ export const IbcWithdraw = (): JSX.Element => { feeProps={feeProps} onComplete={redirectToTimeline} completedAt={completedAt} - isSyncingMasp={requiresNewShieldedSync} + keplrWalletManager={keplrWalletManager} + assetSelectorModalOpen={assetSelectorModalOpen} + setAssetSelectorModalOpen={setAssetSelectorModalOpen} />
); diff --git a/apps/namadillo/src/App/Ibc/ShieldAllIntro.tsx b/apps/namadillo/src/App/Ibc/ShieldAllIntro.tsx index 5f08e6bc85..8d79b46153 100644 --- a/apps/namadillo/src/App/Ibc/ShieldAllIntro.tsx +++ b/apps/namadillo/src/App/Ibc/ShieldAllIntro.tsx @@ -44,7 +44,7 @@ export const ShieldAllIntro = ({ {displayChainModal && ( setDisplayChainModal(false)} /> diff --git a/apps/namadillo/src/App/Ibc/ShieldAllPanel.tsx b/apps/namadillo/src/App/Ibc/ShieldAllPanel.tsx index 090c4c43a1..dcbaffb429 100644 --- a/apps/namadillo/src/App/Ibc/ShieldAllPanel.tsx +++ b/apps/namadillo/src/App/Ibc/ShieldAllPanel.tsx @@ -7,12 +7,7 @@ import { import svgImg from "App/Assets/ShieldedParty.svg"; import { SelectedWallet } from "App/Transfer/SelectedWallet"; import { useEffect, useMemo, useState } from "react"; -import { - Asset, - AssetWithAmount, - ChainRegistryEntry, - WalletProvider, -} from "types"; +import { Asset, AssetWithAmount, ChainRegistryEntry } from "types"; import { SelectableAddressWithAssetAndAmount, ShieldAllAssetList, @@ -22,7 +17,6 @@ import ibcTransferImageBlack from "./assets/ibc-transfer-black.png"; type ShieldAllPanelProps = { registry: ChainRegistryEntry; - wallet: WalletProvider; walletAddress: string; isLoading: boolean; assetList: AssetWithAmount[]; @@ -30,7 +24,6 @@ type ShieldAllPanelProps = { }; export const ShieldAllPanel = ({ - wallet, walletAddress, isLoading, assetList, @@ -95,7 +88,6 @@ export const ShieldAllPanel = ({

Review and confirm assets to shield

{ const features = useAtomValue(applicationFeaturesAtom); + const location = useLocation(); const menuItems: { label: string; icon: React.ReactNode; url?: string }[] = [ { @@ -32,21 +35,26 @@ export const Navigation = (): JSX.Element => { url: routes.governance, }, { - label: "IBC Transfer", - icon: , - url: features.ibcTransfersEnabled ? routes.ibc : undefined, + label: "Shield", + icon: , + url: routes.maspShield, }, { label: "Transfer", - icon: , + icon: Transfer, url: features.maspEnabled || features.namTransfersEnabled ? routes.transfer : undefined, }, + { + label: "Receive", + icon: Receive, + url: routes.receive, + }, { label: "History", - icon: , + icon: , url: features.namTransfersEnabled || features.ibcTransfersEnabled ? routes.history @@ -57,14 +65,53 @@ export const Navigation = (): JSX.Element => { return (
    - {menuItems.map((item) => ( -
  • - - {item.icon} - {item.label} - -
  • - ))} + {menuItems.map((item) => { + const shieldingRoute = item.label === "Shield"; + + const highlightShieldItem = + item.label === "Shield" && + ([routes.maspShield, routes.ibc] as string[]).includes( + location.pathname + ); + + const highlightTransferItem = + item.label === "Transfer" && + ( + [ + routes.maspUnshield, + routes.ibcWithdraw, + routes.transfer, + ] as string[] + ).includes(location.pathname); + + const historyRoute = item.label === "History"; + return ( +
  • + {shieldingRoute && ( + <> +
    +
    Move Assets
    + + )} + + {item.icon} + {item.label} + + {historyRoute && ( +
    + )} +
  • + ); + })}
    diff --git a/apps/namadillo/src/App/Layout/TopNavigation.tsx b/apps/namadillo/src/App/Layout/TopNavigation.tsx index 2dbd8e7491..a6bf33cb60 100644 --- a/apps/namadillo/src/App/Layout/TopNavigation.tsx +++ b/apps/namadillo/src/App/Layout/TopNavigation.tsx @@ -1,14 +1,10 @@ -import { ActionButton } from "@namada/components"; import { AccountType } from "@namada/types"; import { ConnectExtensionButton } from "App/Common/ConnectExtensionButton"; import { TransactionInProgressSpinner } from "App/Common/TransactionInProgressSpinner"; import { UnshieldAssetsModal } from "App/Common/UnshieldAssetsModal"; import { routes } from "App/routes"; import { defaultAccountAtom } from "atoms/accounts"; -import { - applicationFeaturesAtom, - signArbitraryEnabledAtom, -} from "atoms/settings"; +import { signArbitraryEnabledAtom } from "atoms/settings"; import { useUserHasAccount } from "hooks/useIsAuthenticated"; import { useAtomValue } from "jotai"; import { useState } from "react"; @@ -24,9 +20,6 @@ export const TopNavigation = (): JSX.Element => { const userHasAccount = useUserHasAccount(); const signArbitraryEnabled = useAtomValue(signArbitraryEnabledAtom); - const { maspEnabled, namTransfersEnabled } = useAtomValue( - applicationFeaturesAtom - ); const defaultAccount = useAtomValue(defaultAccountAtom); const location = useLocation(); const navigate = useNavigate(); @@ -53,44 +46,7 @@ export const TopNavigation = (): JSX.Element => { return (
    -
    - {maspEnabled && ( - - navigate(routes.shieldAssets, { - state: { backgroundLocation: location }, - }) - } - > - Shield Assets - - )} - {maspEnabled && ( - setUnshieldingModalOpen(true)} - > - Unshield - - )} - {(maspEnabled || namTransfersEnabled) && ( - - Transfer - - )} -
    -
    - {defaultAccount.data?.type !== AccountType.Ledger && signArbitraryEnabled && ( + ); + })} + + {/* Connect Wallet button if Keplr is not connected */} + {!connectedWallets.keplr && ( + + )} +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx b/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx index 976f3e6501..a686143914 100644 --- a/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx +++ b/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx @@ -1,13 +1,17 @@ -import { ActionButton } from "@namada/components"; +import { Asset } from "@chain-registry/types"; +import { FiatCurrency } from "App/Common/FiatCurrency"; import { TokenCurrency } from "App/Common/TokenCurrency"; +import { tokenPricesFamily } from "atoms/prices/atoms"; import BigNumber from "bignumber.js"; import clsx from "clsx"; -import { Asset } from "types"; +import { useAtomValue } from "jotai"; +import { Address } from "types"; type AvailableAmountFooterProps = { availableAmount?: BigNumber; availableAmountMinusFees?: BigNumber; asset?: Asset; + originalAddress?: Address; onClickMax?: () => void; }; @@ -17,54 +21,56 @@ export const AvailableAmountFooter = ({ asset, onClickMax, }: AvailableAmountFooterProps): JSX.Element => { + const tokenPrices = useAtomValue( + tokenPricesFamily(asset?.address ? [asset.address] : []) + ); + if (availableAmountMinusFees === undefined || !asset) { return <>; } const isInsufficientBalance = availableAmountMinusFees.eq(0); + // Calculate dollar value for available amount + const availableDollarAmount = + asset?.address && tokenPrices.data?.[asset.address] ? + availableAmountMinusFees.multipliedBy(tokenPrices.data[asset.address]) + : undefined; return (
    -
    -
    +
    +
    Available:{" "} + {isInsufficientBalance && ( +
    +
    Insufficient balance to cover the fee
    + {availableAmount && ( +
    +
    + Balance:{" "} + +
    +
    + )} +
    + )}
    - {isInsufficientBalance && ( -
    -
    Insufficient balance to cover the fee
    - {availableAmount && ( -
    - Balance:{" "} - -
    - )} -
    + + {availableDollarAmount && ( + )}
    - - {onClickMax && ( - - Max - - )} -
    ); }; diff --git a/apps/namadillo/src/App/Transfer/ChainBadge.tsx b/apps/namadillo/src/App/Transfer/ChainBadge.tsx new file mode 100644 index 0000000000..c7ca567cb4 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/ChainBadge.tsx @@ -0,0 +1,38 @@ +import { Chain } from "@chain-registry/types"; +import { twMerge } from "tailwind-merge"; + +export const ChainBadge = ({ + chain, + className, +}: { + chain?: Chain; + className?: string; +}): JSX.Element => { + if (!chain) { + return <>; + } + + const image = chain?.images?.find((i) => i.theme?.circle === false); + const imageUrl = image?.svg ?? image?.png; + + return ( +
    + {imageUrl ? + {chain.chain_name} + : + {chain.chain_name.charAt(0).toUpperCase()} + + } +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx b/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx index a3fb7e191a..2adc09b90c 100644 --- a/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx +++ b/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx @@ -2,14 +2,17 @@ import { ActionButton } from "@namada/components"; type ConnectProviderButtonProps = { onClick?: () => void; + disabled?: boolean; }; export const ConnectProviderButton = ({ onClick, + disabled, }: ConnectProviderButtonProps): JSX.Element => { return ( { return (
    - + Connected
    ); diff --git a/apps/namadillo/src/App/Transfer/CurrentStatus.tsx b/apps/namadillo/src/App/Transfer/CurrentStatus.tsx index aebd34d25c..a63f22e6aa 100644 --- a/apps/namadillo/src/App/Transfer/CurrentStatus.tsx +++ b/apps/namadillo/src/App/Transfer/CurrentStatus.tsx @@ -17,11 +17,9 @@ export const CurrentStatus = ({ > {status}
    - {explanation && ( -
    - {explanation} -
    - )} +
    + {explanation} +
    ); }; diff --git a/apps/namadillo/src/App/Transfer/CustomAddressForm.tsx b/apps/namadillo/src/App/Transfer/CustomAddressForm.tsx index e8533a1cf7..fa976c7bf0 100644 --- a/apps/namadillo/src/App/Transfer/CustomAddressForm.tsx +++ b/apps/namadillo/src/App/Transfer/CustomAddressForm.tsx @@ -1,50 +1,22 @@ import { Input, Stack } from "@namada/components"; -import { chain as osmosis } from "chain-registry/mainnet/osmosis"; -import { useMemo } from "react"; -import namadaShieldedSvg from "./assets/namada-shielded.svg"; -import namadaTransparentSvg from "./assets/namada-transparent.svg"; - type CustomAddressFormProps = { - onChangeAddress?: (address: string) => void; - customAddress?: string; - memo?: string; - onChangeMemo?: (address: string) => void; + memo: string | undefined; + onChangeMemo: ((memo: string) => void) | undefined; }; export const CustomAddressForm = ({ - customAddress, - onChangeAddress, memo, onChangeMemo, }: CustomAddressFormProps): JSX.Element => { - const iconUrl = useMemo((): string | undefined => { - if (customAddress?.startsWith("osmo")) return osmosis.logo_URIs?.svg; - if (customAddress?.startsWith("znam")) return namadaShieldedSvg; - if (customAddress?.startsWith("tnam")) return namadaTransparentSvg; - return ""; - }, [customAddress]); - return ( - {onChangeAddress && ( - onChangeAddress(e.target.value)} - > - {iconUrl && ( - - - - )} - - )} {onChangeMemo && ( onChangeMemo(e.target.value)} + className="mt-4" placeholder="Insert memo here" /> )} diff --git a/apps/namadillo/src/App/Transfer/DestinationAddressModal.tsx b/apps/namadillo/src/App/Transfer/DestinationAddressModal.tsx new file mode 100644 index 0000000000..746af302ae --- /dev/null +++ b/apps/namadillo/src/App/Transfer/DestinationAddressModal.tsx @@ -0,0 +1,286 @@ +import { Input, Stack } from "@namada/components"; +import { AccountType } from "@namada/types"; +import { shortenAddress } from "@namada/utils"; +import { SelectModal } from "App/Common/SelectModal"; +import { allDefaultAccountsAtom } from "atoms/accounts"; +import { + addToRecentAddresses, + getAddressLabel, + recentAddressesAtom, + validateAddress, + type ValidationResult, +} from "atoms/transactions"; +import clsx from "clsx"; +import { useKeplrAddressForAsset } from "hooks/useKeplrAddressForAsset"; +import { wallets } from "integrations"; +import { getChainFromAddress, getChainImageUrl } from "integrations/utils"; +import { useAtom, useAtomValue } from "jotai"; +import { useState } from "react"; +import { Address, Asset } from "types"; +import namadaShieldedIcon from "./assets/namada-shielded.svg"; +import namadaTransparentIcon from "./assets/namada-transparent.svg"; + +type AddressOption = { + id: string; + label: string; + address: string; + icon: string; + type: "transparent" | "shielded" | "ibc" | "keplr"; +}; + +type DestinationAddressModalProps = { + onClose: () => void; + onSelectAddress: (address: Address) => void; + sourceAddress: string; + sourceAsset?: Asset; +}; + +export const DestinationAddressModal = ({ + sourceAsset, + sourceAddress, + onClose, + onSelectAddress, +}: DestinationAddressModalProps): JSX.Element => { + const [customAddress, setCustomAddress] = useState(""); + const [validationResult, setValidationResult] = + useState(null); + const { data: accounts } = useAtomValue(allDefaultAccountsAtom); + const [recentAddresses, setRecentAddresses] = useAtom(recentAddressesAtom); + const keplrAddress = useKeplrAddressForAsset(sourceAsset); + + const transparentAccount = accounts?.find( + (account) => account.type !== AccountType.ShieldedKeys + ); + const shieldedAccount = accounts?.find( + (account) => account.type === AccountType.ShieldedKeys + ); + + // Dont display an address if it matches the source address + const isSourceAddressMatch = (address: string): boolean => + address === sourceAddress; + + // Build your addresses options + const addressOptions: AddressOption[] = []; + if (accounts) { + const transparentAccount = accounts.find( + (account) => account.type !== AccountType.ShieldedKeys + ); + + if ( + transparentAccount && + !isSourceAddressMatch(transparentAccount.address) + ) { + addressOptions.push({ + id: "transparent", + label: "Transparent Address", + address: transparentAccount.address, + icon: namadaTransparentIcon, + type: "transparent", + }); + } + if (shieldedAccount && !isSourceAddressMatch(shieldedAccount.address)) { + addressOptions.push({ + id: "shielded", + label: "Shielded Address", + address: shieldedAccount.address, + icon: namadaShieldedIcon, + type: "shielded", + }); + } + } + if (keplrAddress) + addressOptions.push({ + id: "keplr", + label: "Keplr Address", + address: keplrAddress ?? "", + icon: + !keplrAddress ? + wallets.keplr.iconUrl + : getChainImageUrl(getChainFromAddress(keplrAddress ?? "")), + type: "keplr", + }); + + // Build recent addresses options + const recentAddressOptions: AddressOption[] = recentAddresses.map( + (recent) => ({ + id: `recent-${recent.address}`, + label: recent.label || getAddressLabel(recent.address, recent.type), + address: recent.address, + icon: + recent.type === "shielded" ? namadaShieldedIcon + : recent.type === "transparent" ? namadaTransparentIcon + : getChainImageUrl(getChainFromAddress(recent.address ?? "")), // fallback for IBC + type: recent.type, + }) + ); + + const handleAddressClick = (address: string): void => { + onSelectAddress(address); + onClose(); + }; + + const handleCustomAddressChange = ( + e: React.ChangeEvent + ): void => { + const value = e.target.value; + setCustomAddress(value); + + // Validate the address as user types + if (value.trim()) { + const result = validateAddress(value); + setValidationResult(result); + } else { + setValidationResult(null); + } + }; + + const handleCustomAddressSubmit = (): void => { + const trimmedAddress = customAddress.trim(); + if (!trimmedAddress) return; + + const result = validateAddress(trimmedAddress); + setValidationResult(result); + + if (result.isValid && result.addressType) { + // Add to recent addresses + const label = getAddressLabel(trimmedAddress, result.addressType); + const updatedRecents = addToRecentAddresses( + recentAddresses, + trimmedAddress, + result.addressType, + label + ); + setRecentAddresses(updatedRecents); + + // Select the address + onSelectAddress(trimmedAddress); + onClose(); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + handleCustomAddressSubmit(); + } + }; + + // Determine input styling based on validation + const getInputClassName = (): string => { + if (!customAddress.trim()) { + return "text-sm border-neutral-500"; + } + + if (validationResult?.isValid) { + return "text-sm border-green-500 focus:border-green-500"; + } else if (validationResult?.error) { + return "text-sm border-red-500 focus:border-red-500"; + } + + return "text-sm border-neutral-500"; + }; + + return ( + +
    +
    + + {validationResult?.error && ( +
    + {validationResult.error.message} +
    + )} +
    + +
    +
    + {addressOptions.length > 0 && ( +
    +

    + Your addresses +

    + + {addressOptions.map((option) => ( + + ))} + +
    + )} + + {recentAddressOptions.length > 0 && ( +
    +

    + Recent addresses +

    + + {recentAddressOptions.map((option) => ( + + ))} + +
    + )} +
    +
    +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/ReceiveCard.tsx b/apps/namadillo/src/App/Transfer/ReceiveCard.tsx index 31ca653f80..c180323814 100644 --- a/apps/namadillo/src/App/Transfer/ReceiveCard.tsx +++ b/apps/namadillo/src/App/Transfer/ReceiveCard.tsx @@ -1,4 +1,4 @@ -import { Panel, SkeletonLoading } from "@namada/components"; +import { Panel, SkeletonLoading, Tooltip } from "@namada/components"; import { copyToClipboard } from "@namada/utils"; import { TabSelector } from "App/Common/TabSelector"; import { allDefaultAccountsAtom } from "atoms/accounts"; @@ -85,12 +85,15 @@ export const ReceiveCard = (): JSX.Element => {
-
+

{address || "No address available"}

+ + {address || "No address available"} + - - ); - })} - {filteredAssets.length === 0 && ( -

There are no available assets

- )} - - - ); -}; diff --git a/apps/namadillo/src/App/Transfer/SelectChainModal.tsx b/apps/namadillo/src/App/Transfer/SelectChainModal.tsx index 1e16a17cff..cfe74970a2 100644 --- a/apps/namadillo/src/App/Transfer/SelectChainModal.tsx +++ b/apps/namadillo/src/App/Transfer/SelectChainModal.tsx @@ -20,7 +20,6 @@ type SelectChainModalProps = { export const SelectChainModal = ({ onClose, onSelect, - wallet, walletAddress, chains, }: SelectChainModalProps): JSX.Element => { @@ -36,9 +35,7 @@ export const SelectChainModal = ({ return ( - {walletAddress && ( - - )} + {walletAddress && }
diff --git a/apps/namadillo/src/App/Transfer/SelectToken.tsx b/apps/namadillo/src/App/Transfer/SelectToken.tsx new file mode 100644 index 0000000000..2f62a84986 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectToken.tsx @@ -0,0 +1,377 @@ +import { Chain } from "@chain-registry/types"; +import { Modal, Stack } from "@namada/components"; +import { ModalTransition } from "App/Common/ModalTransition"; +import { Search } from "App/Common/Search"; +import { + connectedWalletsAtom, + getAvailableChains, + getChainRegistryByChainName, + getNamadaChainRegistry, + namadaRegistryChainAssetsMapAtom, +} from "atoms/integrations"; +import { tokenPricesFamily } from "atoms/prices/atoms"; +import clsx from "clsx"; +import { useWalletManager } from "hooks/useWalletManager"; +import { KeplrWalletManager } from "integrations/Keplr"; +import { getChainFromAsset } from "integrations/utils"; +import { useAtom, useAtomValue } from "jotai"; +import { useMemo, useState } from "react"; +import { IoClose } from "react-icons/io5"; +import { AssetWithAmount } from "types"; +import { AddressDropdown } from "./AddressDropdown"; +import { ChainBadge } from "./ChainBadge"; +import { isNamadaAddress } from "./common"; + +type SelectTokenProps = { + setSourceAddress: (address: string) => void; + sourceAddress: string; + destinationAddress: string; + isOpen: boolean; + onClose: () => void; + onSelect: + | ((selectedAsset: AssetWithAmount, newSourceAddress?: string) => void) + | undefined; + keplrWalletManager?: KeplrWalletManager | undefined; + assetsWithAmounts: AssetWithAmount[]; +}; + +export const SelectToken = ({ + sourceAddress, + destinationAddress, + setSourceAddress, + isOpen, + onClose, + onSelect, + assetsWithAmounts, + keplrWalletManager, +}: SelectTokenProps): JSX.Element | null => { + const [filter, setFilter] = useState(""); + const [selectedNetwork, setSelectedNetwork] = useState(null); + const [isConnectingKeplr, setIsConnectingKeplr] = useState(false); + const [connectedWallets, setConnectedWallets] = useAtom(connectedWalletsAtom); + const chainAssets = useAtomValue(namadaRegistryChainAssetsMapAtom); + const chainAssetsMap = Object.values(chainAssets.data ?? {}); + const ibcChains = useMemo(getAvailableChains, []); + + const allChains = [...ibcChains, getNamadaChainRegistry(false).chain]; + + // Create KeplrWalletManager instance and use with useWalletManager hook + const keplrWallet = keplrWalletManager ?? new KeplrWalletManager(); + const { connectToChainId } = useWalletManager(keplrWallet); + + // Get balances for connected chains + const allNetworks: Chain[] = useMemo(() => { + return allChains + .filter((chain) => chain.network_type !== "testnet") + .sort((a, b) => a.chain_name.localeCompare(b.chain_name)); + }, [chainAssetsMap]); + + // Create a mapping of assets to their network names for better filtering + const assetToNetworkMap = useMemo(() => { + const map: Record = {}; + chainAssetsMap.forEach((asset) => { + if (asset && asset.name) { + // Map asset address to network name + map[asset.address || asset.base] = asset.display; + } + }); + return map; + }, [chainAssetsMap]); + + // Get token prices for USD calculation + const tokenAddresses = assetsWithAmounts + .map((assetWithAmount) => assetWithAmount.asset.address) + .filter((address): address is string => Boolean(address)); + + const tokenPrices = useAtomValue(tokenPricesFamily(tokenAddresses)); + + const filteredTokens = useMemo(() => { + return assetsWithAmounts + .filter((assetWithAmount) => { + // Filter by search term + const matchesSearch = + assetWithAmount.asset.name + .toLowerCase() + .includes(filter.toLowerCase()) || + assetWithAmount.asset.symbol + .toLowerCase() + .includes(filter.toLowerCase()); + + const chainName = + assetWithAmount.asset.name === "Namada" ? + "namada" + : assetWithAmount.asset.traces?.[0].counterparty?.chain_name; + const matchesNetwork = + !selectedNetwork || selectedNetwork === chainName; + + return matchesSearch && matchesNetwork; + }) + .sort((a, b) => Number(b.amount) - Number(a.amount)); + }, [assetsWithAmounts, filter, selectedNetwork, assetToNetworkMap]); + + const handleNetworkSelect = (networkName: string): void => { + setSelectedNetwork(selectedNetwork === networkName ? null : networkName); + }; + + const handleAddressChange = (address: string): void => { + setSourceAddress(address); // Only update local state + setSelectedNetwork(null); // Reset network filter when address changes + }; + + const handleClose = (): void => { + onClose(); + }; + + const handleTokenSelect = async (token: AssetWithAmount): Promise => { + // Check if current address is Keplr and if we need to connect to specific chain for this token + const isIbcOrKeplrToken = !isNamadaAddress(sourceAddress); + let newSourceAddress: string | undefined; + try { + if (isIbcOrKeplrToken) { + setIsConnectingKeplr(true); + + try { + const keplrInstance = await keplrWallet.get(); + // Keplr is not installed, redirect to download page + if (!keplrInstance) { + keplrWallet.install(); + return; + } + + const chainName = + token.asset.base === "unam" ? + "osmosis" + : token.asset.traces?.[0]?.counterparty?.chain_name; + const targetChainRegistry = getChainRegistryByChainName(chainName!); + const chainId = targetChainRegistry?.chain.chain_id as string; + await connectToChainId(chainId); + + // Update connected wallets state only after successful connection + setConnectedWallets((obj: Record) => ({ + ...obj, + [keplrWallet.key]: true, + })); + const key = await keplrInstance.getKey(chainId); + newSourceAddress = key.bech32Address; + } catch (error) { + console.error( + "Failed to connect to Keplr for token:", + token.asset.symbol, + error + ); + // Continue with token selection even if Keplr connection fails + } finally { + setIsConnectingKeplr(false); + } + } + + onSelect?.(token, newSourceAddress); + onClose(); + } catch (error) { + console.error("Error in token selection:", error); + setIsConnectingKeplr(false); + onSelect?.(token, newSourceAddress); + onClose(); + } + }; + + const getOverlayChainLogo = (token: AssetWithAmount): JSX.Element | null => { + const chain = getChainFromAsset(token); + const isNamada = token.asset.symbol === "NAM"; + if (isNamada) return null; + return ( + + ); + }; + + if (!isOpen) return null; + + return ( + <> + + +
+ {/* Left panel */} +
+ +

From A to Z

+ +
  • + +
  • + {allNetworks.map((network) => ( +
  • + +
  • + ))} +
    +
    + + {/* Right panel - Token Selection */} +
    +
    +

    Select Token

    + +
    + +
    + +
    + +
    +
    + + + Your Tokens + + {filteredTokens.length > 0 ? + filteredTokens.map((token) => { + if (token.amount.eq(0)) return null; + const isKeplrAddress = !isNamadaAddress(sourceAddress); + + // For Keplr addresses, only show amounts if we have balance data and it's > 0 + // For Namada addresses, show amounts if > 0 + const showAmount = + isKeplrAddress ? + token.amount.gt(0) && connectedWallets.keplr + : token.amount.gt(0); + return ( +
  • + +
  • + ); + }) + :

    No tokens found

    } +
    +
    +
    + + {isConnectingKeplr && ( +
    + Connecting to Keplr... +
    + )} +
    +
    +
    +
    + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectedAsset.tsx b/apps/namadillo/src/App/Transfer/SelectedAsset.tsx index 714eb1d25a..fc9dee1dc7 100644 --- a/apps/namadillo/src/App/Transfer/SelectedAsset.tsx +++ b/apps/namadillo/src/App/Transfer/SelectedAsset.tsx @@ -21,12 +21,12 @@ export const SelectedAsset = ({ const selectorClassList = clsx( `flex items-center gap-4 text-xl text-white font-light cursor-pointer uppercase` ); - return ( + } +
    + + {customAddress && ( + + + )}
    - - )} + )} + + {isSubmitting && amount && destinationAsset && ( +
    + +
    + )} + + {isSubmitting && ( +
    +
    +
    +
    + {sourceWallet.name} + + {getAddressLabel(destinationAddress ?? "", addressType)} + +
    + {destinationAddress && ( + + )} +
    +
    + )} + + {!isSubmitting && ( +
    + {changeFeeEnabled ? + feeProps && ( + + ) + : gasDisplayAmount && + gasAsset && ( + + ) + } +
    + )} + - {!isSubmitting && ( -
    - {changeFeeEnabled ? - feeProps && ( - - ) - : gasDisplayAmount && - gasAsset && ( - - ) - } -
    + {isModalOpen && ( + )} - + ); }; diff --git a/apps/namadillo/src/App/Transfer/TransferLayout.tsx b/apps/namadillo/src/App/Transfer/TransferLayout.tsx index 63c574772b..9f8831e32e 100644 --- a/apps/namadillo/src/App/Transfer/TransferLayout.tsx +++ b/apps/namadillo/src/App/Transfer/TransferLayout.tsx @@ -1,29 +1,184 @@ +import { Panel } from "@namada/components"; import { NavigationFooter } from "App/AccountOverview/NavigationFooter"; import { ConnectPanel } from "App/Common/ConnectPanel"; import { PageWithSidebar } from "App/Common/PageWithSidebar"; +import { IbcTransfer } from "App/Ibc/IbcTransfer"; +import { IbcWithdraw } from "App/Ibc/IbcWithdraw"; +import { LearnAboutIbc } from "App/Ibc/LearnAboutIbc"; import { Sidebar } from "App/Layout/Sidebar"; +import { LearnAboutMasp } from "App/Masp/LearnAboutMasp"; +import { MaspShield } from "App/Masp/MaspShield"; +import { MaspUnshield } from "App/Masp/MaspUnshield"; import { LearnAboutTransfer } from "App/NamadaTransfer/LearnAboutTransfer"; +import { NamadaTransfer } from "App/NamadaTransfer/NamadaTransfer"; +import { MaspAssetRewards } from "App/Sidebars/MaspAssetRewards"; +import { allDefaultAccountsAtom } from "atoms/accounts"; +import { shieldedBalanceAtom } from "atoms/balance"; +import { applicationFeaturesAtom } from "atoms/settings"; import { useUserHasAccount } from "hooks/useIsAuthenticated"; -import { Outlet } from "react-router-dom"; +import { useUrlState } from "hooks/useUrlState"; +import { KeplrWalletManager } from "integrations/Keplr"; +import { useAtomValue } from "jotai"; +import { useEffect, useState } from "react"; +import { isTransparentAddress } from "."; +import { determineTransferType } from "./utils"; export const TransferLayout: React.FC = () => { + const keplrWalletManager = new KeplrWalletManager(); const userHasAccount = useUserHasAccount(); + const features = useAtomValue(applicationFeaturesAtom); + const [sourceAddress, setSourceAddress] = useUrlState("source"); + const [destinationAddress, setDestinationAddress] = + useUrlState("destination"); + const [assetSelectorModalOpen, setAssetSelectorModalOpen] = useState(false); + + const { refetch: refetchShieldedBalance } = useAtomValue(shieldedBalanceAtom); + const { data: accounts } = useAtomValue(allDefaultAccountsAtom); + + const transferType = determineTransferType({ + sourceAddress, + destinationAddress, + }); + + const transparentAddress = + accounts?.find((acc) => isTransparentAddress(acc.address))?.address ?? ""; + + // Initialize source address + useEffect(() => { + if (!sourceAddress && transparentAddress) { + setSourceAddress(transparentAddress); + } + }, [transparentAddress]); + + // Refetch shielded balance for MASP operations + useEffect(() => { + if (transferType === "shield" || transferType === "unshield") { + refetchShieldedBalance(); + } + }, [transferType, refetchShieldedBalance]); if (!userHasAccount) { - return ; + let actionText = "To transfer assets"; + switch (transferType) { + case "shield": + case "unshield": + actionText = "To shield assets"; + break; + case "ibc-deposit": + case "ibc-withdraw": + actionText = "To IBC Transfer"; + break; + } + return ; } + const renderContent = (): JSX.Element => { + if (transferType === "ibc-deposit") { + return ( + + + + ); + } + + if (transferType === "ibc-withdraw") { + return ( + + + + ); + } + + if (transferType === "shield") { + return ( +
    + +
    + ); + } + + if (transferType === "unshield") { + return ( +
    + +
    + ); + } + + return ( +
    + +
    + ); + }; + + // Render sidebar based on transfer type + const renderSidebar = (): JSX.Element => { + const isIbcTransfer = + transferType === "ibc-deposit" || transferType === "ibc-withdraw"; + const isMaspTransfer = + transferType === "shield" || transferType === "unshield"; + + if (isIbcTransfer) { + return ; + } + + if (isMaspTransfer) { + return ( + <> + {features.shieldingRewardsEnabled && } + + + ); + } + + return ; + }; + return (
    -
    - -
    +
    {renderContent()}
    - - - + {renderSidebar()}
    ); }; diff --git a/apps/namadillo/src/App/Transfer/TransferModule.tsx b/apps/namadillo/src/App/Transfer/TransferModule.tsx index 2d24ca9079..92363d0670 100644 --- a/apps/namadillo/src/App/Transfer/TransferModule.tsx +++ b/apps/namadillo/src/App/Transfer/TransferModule.tsx @@ -1,223 +1,128 @@ -import { Chain } from "@chain-registry/types"; import { ActionButton, Stack } from "@namada/components"; -import { mapUndefined } from "@namada/utils"; import { IconTooltip } from "App/Common/IconTooltip"; import { InlineError } from "App/Common/InlineError"; -import { routes } from "App/routes"; +import { params, routes } from "App/routes"; +import { + namadaShieldedAssetsAtom, + namadaTransparentAssetsAtom, +} from "atoms/balance"; import { namadaRegistryChainAssetsMapAtom } from "atoms/integrations"; import BigNumber from "bignumber.js"; import clsx from "clsx"; +import { useAssetsWithAmounts } from "hooks/useAssetsWithAmounts"; import { useKeychainVersion } from "hooks/useKeychainVersion"; -import { TransactionFeeProps } from "hooks/useTransactionFee"; -import { wallets } from "integrations"; import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; import { BsQuestionCircleFill } from "react-icons/bs"; -import { Link, useLocation, useNavigate } from "react-router-dom"; import { - Address, - AssetWithAmount, - BaseDenom, - GasConfig, - LedgerAccountInfo, - WalletProvider, -} from "types"; + Link, + useLocation, + useNavigate, + useSearchParams, +} from "react-router-dom"; +import { AssetWithAmount } from "types"; import { filterAvailableAssetsWithBalance } from "utils/assets"; -import { checkKeychainCompatibleWithMasp } from "utils/compatibility"; import { getDisplayGasFee } from "utils/gas"; -import { - isShieldedAddress, - isTransparentAddress, - parseChainInfo, -} from "./common"; +import { isIbcAddress, isShieldedAddress } from "./common"; import { CurrentStatus } from "./CurrentStatus"; import { IbcChannels } from "./IbcChannels"; -import { SelectAssetModal } from "./SelectAssetModal"; -import { SelectChainModal } from "./SelectChainModal"; -import { SelectWalletModal } from "./SelectWalletModal"; +import { SelectToken } from "./SelectToken"; import { SuccessAnimation } from "./SuccessAnimation"; import { TransferArrow } from "./TransferArrow"; import { TransferDestination } from "./TransferDestination"; import { TransferSource } from "./TransferSource"; - -type TransferModuleConfig = { - wallet?: WalletProvider; - walletAddress?: string; - availableWallets?: WalletProvider[]; - connected?: boolean; - availableChains?: Chain[]; - chain?: Chain; - isShieldedAddress?: boolean; - onChangeWallet?: (wallet: WalletProvider) => void; - onChangeChain?: (chain: Chain) => void; - onChangeShielded?: (isShielded: boolean) => void; - isSyncingMasp?: boolean; - // Additional information if selected account is a ledger - ledgerAccountInfo?: LedgerAccountInfo; -}; - -export type TransferSourceProps = TransferModuleConfig & { - availableAssets?: Record; - isLoadingAssets?: boolean; - selectedAssetAddress?: Address; - availableAmount?: BigNumber; - onChangeSelectedAsset?: (address: Address | undefined) => void; - amount?: BigNumber; - onChangeAmount?: (amount: BigNumber | undefined) => void; -}; - -export type IbcOptions = { - sourceChannel: string; - onChangeSourceChannel: (channel: string) => void; - destinationChannel?: string; - onChangeDestinationChannel?: (channel: string) => void; -}; - -export type TransferDestinationProps = TransferModuleConfig & { - enableCustomAddress?: boolean; - customAddress?: Address; - onChangeCustomAddress?: (address: Address) => void; - onChangeShielded?: (shielded: boolean) => void; -}; - -export type OnSubmitTransferParams = { - displayAmount: BigNumber; - destinationAddress: Address; - memo?: string; -}; - -export type TransferModuleProps = { - source: TransferSourceProps; - destination: TransferDestinationProps; - onSubmitTransfer?: (params: OnSubmitTransferParams) => void; - requiresIbcChannels?: boolean; - gasConfig?: GasConfig; - feeProps?: TransactionFeeProps; - changeFeeEnabled?: boolean; - submittingText?: string; - isSubmitting?: boolean; - errorMessage?: string; - currentStatus?: string; - currentStatusExplanation?: string; - completedAt?: Date; - isShieldedTx?: boolean; - isSyncingMasp?: boolean; - buttonTextErrors?: Partial>; - onComplete?: () => void; -} & ( - | { ibcTransfer?: undefined; ibcOptions?: undefined } - | { ibcTransfer: "deposit" | "withdraw"; ibcOptions: IbcOptions } -); - -type ValidationResult = - | "NoAmount" - | "NoSourceWallet" - | "NoSourceChain" - | "NoSelectedAsset" - | "NoDestinationWallet" - | "NoDestinationChain" - | "NoTransactionFee" - | "NotEnoughBalance" - | "NotEnoughBalanceForFees" - | "KeychainNotCompatibleWithMasp" - | "CustomAddressNotMatchingChain" - | "TheSameAddress" - | "NoLedgerConnected" - | "Ok"; - -// Check if the provided address is valid for the destination chain and transaction type -const isValidDestinationAddress = ({ - customAddress, - chain, -}: { - customAddress: string; - chain: Chain | undefined; -}): boolean => { - // Skip validation if no custom address or chain provided - if (!customAddress || !chain || !chain.bech32_prefix) return true; - - // Check shielded/transparent address requirements for Namada - if (chain.bech32_prefix === "tnam") { - return ( - isTransparentAddress(customAddress) || isShieldedAddress(customAddress) - ); - } - - // For non-Namada chains, validate using prefix - return customAddress.startsWith(chain.bech32_prefix); -}; +import { TransferModuleProps, ValidationResult } from "./types"; +import { getButtonText, validateTransferForm } from "./utils"; export const TransferModule = ({ source, destination, - gasConfig: gasConfigProp, feeProps, - changeFeeEnabled, - submittingText, isSubmitting, - ibcTransfer, - ibcOptions, - requiresIbcChannels, - onSubmitTransfer, errorMessage, currentStatus, currentStatusExplanation, + gasConfig: gasConfigProp, + onSubmitTransfer, completedAt, onComplete, - buttonTextErrors = {}, - isSyncingMasp = false, - isShieldedTx = false, + ibcChannels, + requiresIbcChannels, + keplrWalletManager, + assetSelectorModalOpen = false, + setAssetSelectorModalOpen = () => {}, }: TransferModuleProps): JSX.Element => { + const { data: usersAssets, isLoading: isLoadingUsersAssets } = useAtomValue( + isShieldedAddress(source.address ?? "") ? + namadaShieldedAssetsAtom + : namadaTransparentAssetsAtom + ); + const [searchParams, setSearchParams] = useSearchParams(); + const asset = searchParams.get(params.asset) || ""; + const assetsWithAmounts = useAssetsWithAmounts(source.address ?? ""); + const selectedAsset = + source.selectedAssetWithAmount ?? + assetsWithAmounts.find( + (assetWithAmount) => assetWithAmount.asset.address === asset + ); + + const availableAmount = selectedAsset?.amount; + const availableAssets = useMemo(() => { + return filterAvailableAssetsWithBalance(usersAssets); + }, [usersAssets]); + const chainAssets = useAtomValue(namadaRegistryChainAssetsMapAtom); + const navigate = useNavigate(); const location = useLocation(); - const [walletSelectorModalOpen, setWalletSelectorModalOpen] = useState(false); - const [sourceChainModalOpen, setSourceChainModalOpen] = useState(false); - const [destinationChainModalOpen, setDestinationChainModalOpen] = - useState(false); - const [assetSelectorModalOpen, setAssetSelectorModalOpen] = useState(false); - const [customAddressActive, setCustomAddressActive] = useState( - destination.enableCustomAddress && !destination.availableWallets - ); - const [memo, setMemo] = useState(); const keychainVersion = useKeychainVersion(); - const chainAssetsMap = useAtomValue(namadaRegistryChainAssetsMapAtom); + const isTargetShielded = isShieldedAddress(destination.address ?? ""); + const isSourceShielded = isShieldedAddress(source.address ?? ""); + const isShielding = + isShieldedAddress(destination.address ?? "") && + !isShieldedAddress(source.address ?? ""); + const isUnshielding = + isShieldedAddress(source.address ?? "") && + !isShieldedAddress(destination.address ?? ""); + const isShieldedTx = isShieldedAddress(source.address ?? ""); + const buttonColor = isTargetShielded || isSourceShielded ? "yellow" : "white"; + const ibcTransfer = + isIbcAddress(destination.address ?? "") || + isIbcAddress(source.address ?? ""); + + const getButtonTextFromValidation = (): string => { + const buttonTextErrors = + isShielding || isUnshielding ? + { + NoAmount: + isShielding ? "Define an amount to shield" + : isUnshielding ? "Define an amount to unshield" + : "", + } + : {}; + + return getButtonText({ + isSubmitting, + validationResult, + availableAmountMinusFees, + buttonTextErrors, + }); + }; - const chainAssets = Object.values(chainAssetsMap.data || {}) ?? []; const gasConfig = gasConfigProp ?? feeProps?.gasConfig; const displayGasFee = useMemo(() => { return gasConfig ? - getDisplayGasFee(gasConfig, chainAssetsMap.data || {}) + getDisplayGasFee(gasConfig, chainAssets.data ?? {}) : undefined; }, [gasConfig]); - const availableAssets: Record = - useMemo(() => { - return filterAvailableAssetsWithBalance(source.availableAssets); - }, [source.availableAssets]); - - const firstAvailableAsset = Object.values(availableAssets)[0]; - - const selectedAsset = mapUndefined( - (address) => source.availableAssets?.[address], - source.selectedAssetAddress - ); - const availableAmountMinusFees = useMemo(() => { - const { selectedAssetAddress, availableAmount } = source; - - if ( - typeof selectedAssetAddress === "undefined" || - typeof availableAmount === "undefined" || - typeof availableAssets === "undefined" - ) { + if (!selectedAsset?.asset.address || !availableAmount || !availableAssets) return undefined; - } - if ( !displayGasFee?.totalDisplayAmount || // Don't subtract if the gas token is different than the selected asset: - gasConfig?.gasToken !== selectedAssetAddress + gasConfig?.gasToken !== selectedAsset?.asset.address ) { return availableAmount; } @@ -227,250 +132,56 @@ export const TransferModule = ({ .decimalPlaces(6); return BigNumber.max(amountMinusFees, 0); - }, [source.selectedAssetAddress, source.availableAmount, displayGasFee]); + }, [selectedAsset?.asset.address, availableAmount, displayGasFee]); const validationResult = useMemo((): ValidationResult => { - if (!source.wallet) { - return "NoSourceWallet"; - } else if (source.walletAddress === destination.customAddress) { - return "TheSameAddress"; - } else if ( - !isValidDestinationAddress({ - customAddress: destination.customAddress ?? "", - chain: destination.chain, - }) - ) { - return "CustomAddressNotMatchingChain"; - } else if ( - (source.isShieldedAddress || destination.isShieldedAddress) && - keychainVersion && - !checkKeychainCompatibleWithMasp(keychainVersion) - ) { - return "KeychainNotCompatibleWithMasp"; - } else if (!source.chain) { - return "NoSourceChain"; - } else if (!destination.chain) { - return "NoDestinationChain"; - } else if (!source.selectedAssetAddress) { - return "NoSelectedAsset"; - } else if (!hasEnoughBalanceForFees()) { - return "NotEnoughBalanceForFees"; - } else if (!source.amount || source.amount.eq(0)) { - return "NoAmount"; - } else if ( - !availableAmountMinusFees || - source.amount.gt(availableAmountMinusFees) - ) { - return "NotEnoughBalance"; - } else if (!destination.wallet && !destination.customAddress) { - return "NoDestinationWallet"; - } else if ( - (source.isShieldedAddress || destination.isShieldedAddress) && - source.ledgerAccountInfo && - !source.ledgerAccountInfo.deviceConnected - ) { - return "NoLedgerConnected"; - } else { - return "Ok"; - } - }, [source, destination, gasConfig, availableAmountMinusFees]); + return validateTransferForm({ + source: { + assetAddress: selectedAsset?.asset.address, + address: source.address, + isShieldedAddress: isShieldedAddress(source.address ?? ""), + selectedAssetAddress: selectedAsset?.asset.address, + amount: source.amount, + ledgerAccountInfo: source.ledgerAccountInfo, + }, + destination: { + address: destination.address, + isShieldedAddress: isShieldedAddress(destination.address ?? ""), + }, + gasConfig, + availableAmountMinusFees, + keychainVersion, + availableAssets, + displayGasFeeAmount: displayGasFee?.totalDisplayAmount, + }); + }, [ + source.address, + selectedAsset?.asset.address, + source.amount, + source.ledgerAccountInfo, + gasConfig, + availableAmountMinusFees, + keychainVersion, + availableAssets, + displayGasFee, + ]); const onSubmit = (e: React.FormEvent): void => { e.preventDefault(); - const address = destination.customAddress || destination.walletAddress; - if (!source.amount) { - throw new Error("Amount is not valid"); - } - - if (!address) { - throw new Error("Address is not provided"); - } - - if (!source.selectedAssetAddress) { - throw new Error("Asset is not selected"); - } - - const params: OnSubmitTransferParams = { - displayAmount: source.amount, - destinationAddress: address.trim(), - memo, - }; - - onSubmitTransfer?.(params); - }; - - const onChangeWallet = (config: TransferModuleConfig) => (): void => { - // No callback available, do nothing - if (!config.onChangeWallet) return; - - // User may choose between multiple options - if ((config.availableWallets || []).length > 1) { - setWalletSelectorModalOpen(true); - return; - } - - // Fallback to default wallet prop - if (!config.availableWallets && config.wallet) { - config.onChangeWallet(config.wallet); - return; - } - - // Do nothing if no alternatives are provided - if (!config.availableWallets) { - return; - } - - // Do nothing if wallet address is set, and no other wallet is available - if (config.walletAddress && config.availableWallets.length <= 1) { - return; - } - - setWalletSelectorModalOpen(true); - }; - - function hasEnoughBalanceForFees(): boolean { - // Skip if transaction fees will be handled by another wallet, like Keplr. - // (Ex: when users transfer from IBC to Namada) - if (source.wallet && source.wallet !== wallets.namada) { - return true; - } - - if (!availableAssets || !gasConfig || !displayGasFee) { - return false; - } - - // Find how much the user has in their account for the selected fee token - const feeTokenAddress = gasConfig.gasToken; - - if (!availableAssets.hasOwnProperty(feeTokenAddress)) { - return false; - } - - const assetDisplayAmount = availableAssets[feeTokenAddress].amount; - const feeDisplayAmount = displayGasFee?.totalDisplayAmount; - - return assetDisplayAmount.gt(feeDisplayAmount); - } - - const filteredAvailableAssets = useMemo(() => { - // Get available assets that are accepted by the chain - return Object.values(availableAssets).filter(({ asset }) => { - if (!source.chain) return true; - return chainAssets.some( - (chainAsset) => - chainAsset?.symbol.toLowerCase() === asset?.symbol.toLowerCase() - ); + onSubmitTransfer({ + displayAmount: source.amount?.toString() ?? "", + destinationAddress: destination.address, + sourceAddress: source.address, + memo: destination.memo, }); - }, [availableAssets, source.chain, chainAssets]); - - const sortedAssets = useMemo(() => { - if (!filteredAvailableAssets.length) { - return []; - } - - // Sort filtered assets by amount - return [...filteredAvailableAssets].sort( - (asset1: AssetWithAmount, asset2: AssetWithAmount) => { - return asset1.amount.gt(asset2.amount) ? -1 : 1; - } - ); - }, [filteredAvailableAssets]); - - const getButtonTextError = ( - id: ValidationResult, - defaultText: string - ): string => { - if (buttonTextErrors.hasOwnProperty(id) && buttonTextErrors[id]) { - return buttonTextErrors[id]; - } - - return defaultText; - }; - - const getButtonText = (): string | JSX.Element => { - if (isSubmitting) { - return submittingText || "Submitting..."; - } - - const getText = getButtonTextError.bind(null, validationResult); - switch (validationResult) { - case "NoSourceWallet": - return getText("Select Wallet"); - case "TheSameAddress": - return getText("Source and destination addresses are the same"); - - case "NoSourceChain": - case "NoDestinationChain": - return getText("Select Chain"); - - case "NoSelectedAsset": - return getText("Select Asset"); - case "NoDestinationWallet": - return getText("Select Destination Wallet"); - - case "NoAmount": - return getText("Define an amount to transfer"); - - case "NoTransactionFee": - return getText("No transaction fee is set"); - - case "CustomAddressNotMatchingChain": - return getText("Custom address does not match chain"); - case "NotEnoughBalance": - return getText("Not enough balance"); - case "NotEnoughBalanceForFees": - return getText("Not enough balance to pay for transaction fees"); - case "KeychainNotCompatibleWithMasp": - return getText("Keychain is not compatible with MASP"); - case "NoLedgerConnected": - return getText("Connect your ledger and open the Namada App"); - } - - if (!availableAmountMinusFees) { - return getText("Wallet amount not available"); - } - - return "Submit"; }; - const buttonColor = - destination.isShieldedAddress || source.isShieldedAddress ? - "yellow" - : "white"; - - const renderLedgerTooltip = useCallback( - () => ( - } - text={ - - If your device is connected and the app is open, please go to{" "} - { - e.preventDefault(); - navigate(routes.settingsLedger, { - state: { backgroundLocation: location }, - }); - }} - to={routes.settingsLedger} - className="text-yellow" - > - Settings - {" "} - and pair your device with Namadillo. - - } - /> - ), - [] - ); - + // Set the selected asset in the parent component from the URL state if it's not set useEffect(() => { - if (!selectedAsset?.asset && firstAvailableAsset) { - source.onChangeSelectedAsset?.(firstAvailableAsset?.asset.address); + if (!source.selectedAssetWithAmount && selectedAsset) { + source.onChangeSelectedAsset(selectedAsset); } - }, [firstAvailableAsset]); + }, [selectedAsset]); return ( <> @@ -484,85 +195,55 @@ export const TransferModule = ({ onSubmit={onSubmit} > setSourceChainModalOpen(true) - : undefined - } openAssetSelector={ - source.onChangeSelectedAsset && !isSubmitting ? - () => setAssetSelectorModalOpen(true) - : undefined + !isSubmitting ? () => setAssetSelectorModalOpen(true) : undefined } onChangeAmount={source.onChangeAmount} - isShieldedAddress={source.isShieldedAddress} - onChangeShielded={source.onChangeShielded} isSubmitting={isSubmitting} /> - destination.onChangeWallet && destination.wallet ? - destination.onChangeWallet(destination.wallet) - : undefined - } - openChainSelector={ - destination.onChangeChain && !isSubmitting ? - () => setDestinationChainModalOpen(true) - : undefined - } - onChangeAddress={destination.onChangeCustomAddress} - memo={memo} - onChangeMemo={setMemo} + destinationAddress={destination.address} + sourceAsset={selectedAsset?.asset} + sourceAddress={source.address} + onChangeAddress={destination.onChangeAddress} + memo={destination.memo} + onChangeMemo={destination.onChangeMemo} feeProps={feeProps} - changeFeeEnabled={changeFeeEnabled} gasDisplayAmount={displayGasFee?.totalDisplayAmount} gasAsset={displayGasFee?.asset} destinationAsset={selectedAsset?.asset} amount={source.amount} isSubmitting={isSubmitting} /> - {ibcTransfer && requiresIbcChannels && ( + {ibcTransfer && requiresIbcChannels && ibcChannels && ( )} {!isSubmitting && } @@ -572,7 +253,7 @@ export const TransferModule = ({ explanation={currentStatusExplanation} /> )} - {!isSubmitting && onSubmitTransfer && ( + {!isSubmitting && (
    - {getButtonText()} + {getButtonTextFromValidation()} - - {validationResult === "NoLedgerConnected" && - renderLedgerTooltip()} + {validationResult === "NoLedgerConnected" && ( + + } + text={ + + If your device is connected and the app is open, please go + to{" "} + { + e.preventDefault(); + navigate(routes.settingsLedger, { + state: { backgroundLocation: location }, + }); + }} + to={routes.settingsLedger} + className="text-yellow" + > + Settings + {" "} + and pair your device with Namadillo. + + } + /> + )}
    )} {validationResult === "KeychainNotCompatibleWithMasp" && ( @@ -604,54 +309,35 @@ export const TransferModule = ({ /> )} - - {walletSelectorModalOpen && - source.onChangeWallet && - source.availableWallets && ( - setWalletSelectorModalOpen(false)} - onConnect={source.onChangeWallet} - /> - )} - - {assetSelectorModalOpen && - source.onChangeSelectedAsset && - source.wallet && - source.walletAddress && ( - setAssetSelectorModalOpen(false)} - assets={sortedAssets.map( - (assetsWithAmount) => assetsWithAmount.asset - )} - onSelect={source.onChangeSelectedAsset} - wallet={source.wallet} - walletAddress={source.walletAddress} - ibcTransfer={ibcTransfer} - /> - )} - - {sourceChainModalOpen && source.onChangeChain && source.wallet && ( - setSourceChainModalOpen(false)} - chains={source.availableChains || []} - onSelect={source.onChangeChain} - wallet={source.wallet} - walletAddress={source.walletAddress} - /> - )} - - {destinationChainModalOpen && - destination.onChangeChain && - destination.wallet && ( - setDestinationChainModalOpen(false)} - chains={destination.availableChains || []} - onSelect={destination.onChangeChain} - wallet={destination.wallet} - walletAddress={destination.walletAddress} - /> - )} + setAssetSelectorModalOpen(false)} + assetsWithAmounts={assetsWithAmounts} + onSelect={( + selectedAssetWithAmount: AssetWithAmount, + newSourceAddress?: string + ) => { + // Batch both URL updates together + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + if (newSourceAddress) { + newParams.set("source", newSourceAddress); + } + newParams.set( + params.asset, + selectedAssetWithAmount.asset.address || "" + ); + return newParams; + }); + source.onChangeAmount(undefined); + source.onChangeSelectedAsset(selectedAssetWithAmount); + setAssetSelectorModalOpen(false); + }} + /> ); }; diff --git a/apps/namadillo/src/App/Transfer/TransferSource.tsx b/apps/namadillo/src/App/Transfer/TransferSource.tsx index 32cbc49c96..e52ef11abc 100644 --- a/apps/namadillo/src/App/Transfer/TransferSource.tsx +++ b/apps/namadillo/src/App/Transfer/TransferSource.tsx @@ -1,35 +1,30 @@ -import { Chain } from "@chain-registry/types"; -import { AmountInput } from "@namada/components"; -import { TabSelector } from "App/Common/TabSelector"; -import { MaspSyncIndicator } from "App/Layout/MaspSyncIndicator"; +import { Asset } from "@chain-registry/types"; +import { AmountInput, Tooltip } from "@namada/components"; import BigNumber from "bignumber.js"; import clsx from "clsx"; -import { Asset, WalletProvider } from "types"; +import { wallets } from "integrations"; +import { Address } from "types"; +import namadaShieldedIcon from "./assets/namada-shielded.svg"; +import namadaTransparentIcon from "./assets/namada-transparent.svg"; import { AvailableAmountFooter } from "./AvailableAmountFooter"; -import { ConnectProviderButton } from "./ConnectProviderButton"; +import { isShieldedAddress, isTransparentAddress } from "./common"; import { SelectedAsset } from "./SelectedAsset"; -import { SelectedChain } from "./SelectedChain"; -import { SelectedWallet } from "./SelectedWallet"; import { TokenAmountCard } from "./TokenAmountCard"; export type TransferSourceProps = { - isConnected: boolean; - wallet?: WalletProvider; - walletAddress?: string; - asset?: Asset; isLoadingAssets?: boolean; isSubmitting?: boolean; - isSyncingMasp?: boolean; - chain?: Chain; - openChainSelector?: () => void; - openAssetSelector?: () => void; - openProviderSelector?: () => void; - amount?: BigNumber; + isShieldingTxn?: boolean; + asset?: Asset; + originalAddress?: Address; + sourceAddress?: string; availableAmount?: BigNumber; availableAmountMinusFees?: BigNumber; + amount?: BigNumber; + selectedTokenType?: "shielded" | "transparent" | "keplr"; + openAssetSelector?: () => void; + openProviderSelector?: () => void; onChangeAmount?: (amount: BigNumber | undefined) => void; - isShieldedAddress?: boolean; - onChangeShielded?: (isShielded: boolean) => void; }; const amountMaxDecimalPlaces = (asset?: Asset): number | undefined => { @@ -43,111 +38,94 @@ const amountMaxDecimalPlaces = (asset?: Asset): number | undefined => { return undefined; }; +const getWalletIcon = ( + sourceAddress?: string, + selectedTokenType?: "shielded" | "transparent" | "keplr" +): string => { + if (!sourceAddress) return ""; + + // Use selected token type if available, otherwise fall back to source address format + if (selectedTokenType) { + switch (selectedTokenType) { + case "shielded": + return namadaShieldedIcon; + case "transparent": + return namadaTransparentIcon; + case "keplr": + return wallets.keplr.iconUrl; + default: + break; + } + } + + // Fallback to original logic if token type not specified + if (isShieldedAddress(sourceAddress)) { + return namadaShieldedIcon; + } else if (isTransparentAddress(sourceAddress)) { + return namadaTransparentIcon; + } else { + return wallets.keplr.iconUrl; + } +}; + export const TransferSource = ({ - chain, - asset, isLoadingAssets, - wallet, - walletAddress, - openProviderSelector, - openChainSelector, - openAssetSelector, + isSubmitting, + asset, + originalAddress, availableAmount, availableAmountMinusFees, amount, + sourceAddress, + openAssetSelector, onChangeAmount, - isShieldedAddress, - isSyncingMasp, - onChangeShielded, - isSubmitting, }: TransferSourceProps): JSX.Element => { + const selectedTokenType = + isTransparentAddress(sourceAddress ?? "") ? "transparent" + : isShieldedAddress(sourceAddress ?? "") ? "shielded" + : "keplr"; + return ( -
    - {/** Intro header - Ex: "IBC To Namada" */} - {onChangeShielded && chain?.chain_name === "namada" && !isSubmitting && ( -
    - } - syncedChildren={
    Shielded sync completed
    } - /> - - )} - - ), - className: - isShieldedAddress ? "text-yellow" : ( - clsx("text-yellow/50", { - "hover:text-yellow/80": !isSyncingMasp, - }) - ), - buttonProps: { disabled: isSyncingMasp }, - }, - { - id: "transparent", - text: "Transparent", - className: - !isShieldedAddress ? "text-white" : ( - "text-white/50 hover:text-white/80" - ), - }, - ]} - onChange={() => onChangeShielded(!isShieldedAddress)} - /> - +
    - - {!walletAddress && ( - - )} - {walletAddress && wallet && ( - + {sourceAddress && ( +
    +
    + Wallet icon + + {sourceAddress} + +
    +
    )}
    -
    - {/** Asset selector */} {!isSubmitting && ( -
    - +
    onChangeAmount?.(e.target.value)} placeholder="Amount" @@ -156,13 +134,13 @@ export const TransferSource = ({
    )} - {/** Available amount footer */} {!isSubmitting && asset && availableAmountMinusFees && (