diff --git a/apps/namadillo/package.json b/apps/namadillo/package.json index 4432d80bd..8ba7e0325 100644 --- a/apps/namadillo/package.json +++ b/apps/namadillo/package.json @@ -8,6 +8,7 @@ "private": true, "dependencies": { "@anomaorg/namada-indexer-client": "0.0.23", + "@chain-registry/client": "^1.48.80", "@cosmjs/encoding": "^0.32.3", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/query-core": "^5.40.0", diff --git a/apps/namadillo/src/App/Common/SelectModal.tsx b/apps/namadillo/src/App/Common/SelectModal.tsx index 6e92dcc7f..448596d51 100644 --- a/apps/namadillo/src/App/Common/SelectModal.tsx +++ b/apps/namadillo/src/App/Common/SelectModal.tsx @@ -2,31 +2,39 @@ import { Modal } from "@namada/components"; import clsx from "clsx"; import React from "react"; import { IoClose } from "react-icons/io5"; +import { twMerge } from "tailwind-merge"; import { ModalTransition } from "./ModalTransition"; type SelectModalProps = { children: React.ReactNode; title: React.ReactNode; onClose: () => void; -}; +} & React.ComponentPropsWithoutRef<"div">; export const SelectModal = ({ children, title, onClose, + className, + ...props }: SelectModalProps): JSX.Element => { return (
-
+
{title} diff --git a/apps/namadillo/src/App/Common/TabSelector.tsx b/apps/namadillo/src/App/Common/TabSelector.tsx index 29e0eea6b..b25c5864f 100644 --- a/apps/namadillo/src/App/Common/TabSelector.tsx +++ b/apps/namadillo/src/App/Common/TabSelector.tsx @@ -18,25 +18,25 @@ export const TabSelector = ({ onChange, }: TabSelectorProps): JSX.Element => { return ( - +
    + {items.map((item) => ( +
  • + +
  • + ))} +
); }; diff --git a/apps/namadillo/src/App/Transfer/AssetCard.tsx b/apps/namadillo/src/App/Transfer/AssetCard.tsx new file mode 100644 index 000000000..ced7a5cbd --- /dev/null +++ b/apps/namadillo/src/App/Transfer/AssetCard.tsx @@ -0,0 +1,31 @@ +import { Asset } from "@chain-registry/types"; +import clsx from "clsx"; + +type AssetCardProps = { + asset: Asset; +}; + +export const AssetCard = ({ asset }: AssetCardProps): JSX.Element => { + const image = asset.logo_URIs?.svg || asset.logo_URIs?.png; + return ( + + {image ? + {asset.name + : Logo not available + } + {asset.name} + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx b/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx index e4a50c47a..093f72d19 100644 --- a/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx +++ b/apps/namadillo/src/App/Transfer/AvailableAmountFooter.tsx @@ -18,6 +18,7 @@ export const AvailableAmountFooter = ({ return <>; } + // TODO: Replace usage here return (
diff --git a/apps/namadillo/src/App/Transfer/ChainCard.tsx b/apps/namadillo/src/App/Transfer/ChainCard.tsx index e69de29bb..8d76014ce 100644 --- a/apps/namadillo/src/App/Transfer/ChainCard.tsx +++ b/apps/namadillo/src/App/Transfer/ChainCard.tsx @@ -0,0 +1,19 @@ +import { Chain } from "@chain-registry/types"; +import clsx from "clsx"; + +type ChainCardProps = { + chain: Chain; +}; + +export const ChainCard = ({ chain }: ChainCardProps): JSX.Element => { + return ( + + {chain.pretty_name + {chain.pretty_name} + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx b/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx index b5935dd22..211cee4c5 100644 --- a/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx +++ b/apps/namadillo/src/App/Transfer/ConnectProviderButton.tsx @@ -10,7 +10,7 @@ export const ConnectProviderButton = ({ return ( { + const [selectedWallet, setWallet] = useAtom(selectedIBCWallet); + const [chainId, setChainId] = useAtom(selectedIBCChainAtom); + const [selectedAsset, setSelectedAsset] = useState(); + const [isShielded, setShielded] = useState(true); + + const sourceChainConfig: [Chain, Asset[]][] = [ + [cosmos, cosmosAssets.assets], + [osmosis, osmosisAssets.assets], + [celestia, celestiaAssets.assets], + [dydx, dydxAssets.assets], + [stride, strideAssets.assets], + [stargaze, stargazeAssets.assets], + ]; + + const sourceChains: Record = useMemo(() => { + return sourceChainConfig.reduce((prev, current) => { + return { + ...prev, + [current[0].chain_id]: current[0], + }; + }, {}); + }, []); + + const sourceAssetList: Asset[] | undefined = useMemo(() => { + if (!chainId) return; + const config = sourceChainConfig.find( + (config) => config[0].chain_id === chainId + ); + if (config) { + return config[1]; + } + }, [chainId]); + + const selectedSourceChain = + chainId && chainId in sourceChains ? sourceChains[chainId] : undefined; + + useEffect(() => { + const config = sourceChainConfig.find( + (config) => config[0].chain_id === chainId + ); + + if (config) { + setSelectedAsset(config[1][0]); + } + }, [chainId]); + + return ( + + {}} + availableWallets={Object.values(wallets)} + sourceWallet={selectedWallet ? wallets[selectedWallet] : undefined} + onChangeWallet={async (wallet: WalletProvider) => { + try { + await integrations[wallet.id].connect(); + setWallet(wallet.id); + if (!chainId) { + setChainId(cosmos.chain_id); + } + } catch (err) { + console.error(err); + } + }} + onChangeSourceChain={(chain) => { + setChainId(chain.chain_id); + }} + onChangeSelectedAsset={setSelectedAsset} + availableSourceChains={Object.values(sourceChains)} + availableAssets={sourceAssetList} + selectedAsset={selectedAsset} + sourceChain={selectedSourceChain} + destinationChain={namadaChain as Chain} + destinationWallet={wallets.namada} + isShielded={isShielded} + onChangeShielded={setShielded} + availableAmount={new BigNumber(100) /* Change this */} + transactionFee={new BigNumber(0.01)} + /> + + ); +}; diff --git a/apps/namadillo/src/App/Transfer/IBCFromNamadaModule.tsx b/apps/namadillo/src/App/Transfer/IBCFromNamadaModule.tsx deleted file mode 100644 index 088f5a72c..000000000 --- a/apps/namadillo/src/App/Transfer/IBCFromNamadaModule.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { TransferModule } from "./TransferModule"; - -export const IBCFromNamadaModule = (): JSX.Element => { - return ( -
- {}} /> -
- ); -}; diff --git a/apps/namadillo/src/App/Transfer/IBCTransfers.tsx b/apps/namadillo/src/App/Transfer/IBCTransfers.tsx deleted file mode 100644 index 3a551ec52..000000000 --- a/apps/namadillo/src/App/Transfer/IBCTransfers.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Panel } from "@namada/components"; -import { IBCFromNamadaModule } from "./IBCFromNamadaModule"; - -export const IBCTransfers = (): JSX.Element => { - return ( -
- - - -
- ); -}; diff --git a/apps/namadillo/src/App/Transfer/SelectAssetModal.tsx b/apps/namadillo/src/App/Transfer/SelectAssetModal.tsx new file mode 100644 index 000000000..4dbb76ea6 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectAssetModal.tsx @@ -0,0 +1,61 @@ +import { Asset } from "@chain-registry/types"; +import { Stack } from "@namada/components"; +import { Search } from "App/Common/Search"; +import { SelectModal } from "App/Common/SelectModal"; +import clsx from "clsx"; +import { useMemo, useState } from "react"; +import { twMerge } from "tailwind-merge"; +import { AssetCard } from "./AssetCard"; + +type SelectWalletModalProps = { + onClose: () => void; + onSelect: (asset: Asset) => void; + assets: Asset[]; +}; + +export const SelectAssetModal = ({ + onClose, + onSelect, + assets, +}: SelectWalletModalProps): JSX.Element => { + const [filter, setFilter] = useState(""); + + const filteredAssets = useMemo(() => { + return assets.filter( + (asset) => + asset.name.indexOf(filter) >= 0 || asset.symbol.indexOf(filter) >= 0 + ); + }, [assets, filter]); + + return ( + +
+ +
+ + {filteredAssets.map((asset: Asset, index: number) => ( +
  • + +
  • + ))} +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectChainModal.tsx b/apps/namadillo/src/App/Transfer/SelectChainModal.tsx index 61fed101b..48056abd0 100644 --- a/apps/namadillo/src/App/Transfer/SelectChainModal.tsx +++ b/apps/namadillo/src/App/Transfer/SelectChainModal.tsx @@ -1,34 +1,61 @@ +import { Chain, Chains } from "@chain-registry/types"; import { Stack } from "@namada/components"; +import { Search } from "App/Common/Search"; import { SelectModal } from "App/Common/SelectModal"; import clsx from "clsx"; -import { Chain } from "types"; +import { useMemo, useState } from "react"; +import { twMerge } from "tailwind-merge"; +import { ChainCard } from "./ChainCard"; type SelectChainModalProps = { onClose: () => void; - chains: Chain[]; + onSelect: (chain: Chain) => void; + chains: Chains; }; export const SelectChainModal = ({ onClose, + onSelect, chains, }: SelectChainModalProps): JSX.Element => { + const [filter, setFilter] = useState(""); + + const filteredChains = useMemo(() => { + return chains.filter((chain) => chain.pretty_name.indexOf(filter) >= 0); + }, [chains, filter]); + return ( - - {chains.map((chain) => ( -
  • - -
  • - ))} -
    +
    + +
    + {filteredChains.length > 0 && ( + + {filteredChains.map((chain) => ( +
  • + +
  • + ))} +
    + )} + {filteredChains.length === 0 &&

    There are no available chains

    }
    ); }; diff --git a/apps/namadillo/src/App/Transfer/SelectProviderModal.tsx b/apps/namadillo/src/App/Transfer/SelectProviderModal.tsx deleted file mode 100644 index 88fcb4eb7..000000000 --- a/apps/namadillo/src/App/Transfer/SelectProviderModal.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { SelectModal } from "App/Common/SelectModal"; -import { Provider } from "types"; - -type SelectProviderModalProps = { - onClose: () => void; - providers: Provider[]; - onConnect: (provider: Provider) => void; -}; - -export const SelectProviderModal = ({ - onClose, - onConnect, - providers, -}: SelectProviderModalProps): JSX.Element => { - return ( - -
      - {providers.map((provider: Provider, index) => ( -
    • - -
    • - ))} -
    -
    - ); -}; diff --git a/apps/namadillo/src/App/Transfer/SelectWalletModal.tsx b/apps/namadillo/src/App/Transfer/SelectWalletModal.tsx new file mode 100644 index 000000000..4011dac83 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectWalletModal.tsx @@ -0,0 +1,46 @@ +import { integrations } from "@namada/integrations"; +import { SelectModal } from "App/Common/SelectModal"; +import { WalletProvider } from "types"; +import { WalletCard } from "./WalletCard"; + +type SelectWalletModalProps = { + onClose: () => void; + wallets: WalletProvider[]; + onConnect: (wallet: WalletProvider) => void; +}; + +export const SelectWalletModal = ({ + onClose, + onConnect, + wallets, +}: SelectWalletModalProps): JSX.Element => { + const isConnected = (_wallet: WalletProvider): boolean => { + return false; + }; + + const isInstalled = (wallet: WalletProvider): boolean => { + if (wallet.id in integrations) { + return integrations[wallet.id].detect(); + } + return false; + }; + + return ( + +
      + {wallets + .filter((wallet) => wallet.id !== "namada") + .map((wallet: WalletProvider, index) => ( +
    • + onConnect(wallet)} + /> +
    • + ))} +
    +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/SelectedAsset.tsx b/apps/namadillo/src/App/Transfer/SelectedAsset.tsx index 4f86e205b..ca101a544 100644 --- a/apps/namadillo/src/App/Transfer/SelectedAsset.tsx +++ b/apps/namadillo/src/App/Transfer/SelectedAsset.tsx @@ -1,6 +1,6 @@ +import { Asset, Chain } from "@chain-registry/types"; import clsx from "clsx"; import { GoChevronDown } from "react-icons/go"; -import { Asset, Chain } from "types"; import { EmptyResourceIcon } from "./EmptyResourceIcon"; type SelectedAssetProps = { @@ -15,14 +15,18 @@ export const SelectedAsset = ({ onClick, }: SelectedAssetProps): JSX.Element => { const selectorClassList = clsx( - `flex items-center gap-2.5 text-lg text-white font-light cursor-pointer uppercase` + `flex items-center gap-4 text-xl text-white font-light cursor-pointer uppercase` ); + const isDisabled = !chain; + return ( diff --git a/apps/namadillo/src/App/Transfer/SelectedChain.tsx b/apps/namadillo/src/App/Transfer/SelectedChain.tsx index c2f923d0e..619fa4df2 100644 --- a/apps/namadillo/src/App/Transfer/SelectedChain.tsx +++ b/apps/namadillo/src/App/Transfer/SelectedChain.tsx @@ -1,51 +1,61 @@ +import { Chain } from "@chain-registry/types"; import clsx from "clsx"; import { GoChevronDown } from "react-icons/go"; -import { Chain, Provider } from "types"; +import { WalletProvider } from "types"; + import { EmptyResourceIcon } from "./EmptyResourceIcon"; type SelectedChainProps = { chain?: Chain; - provider?: Provider; + wallet?: WalletProvider; onClick?: () => void; + iconSize?: string; }; export const SelectedChain = ({ chain, - provider, + wallet, onClick, + iconSize, }: SelectedChainProps): JSX.Element => { const selectorClassList = clsx( - `flex items-center gap-2.5 text-white font-light cursor-pointer` + `flex items-center gap-2.5 text-white font-light`, + { "cursor-auto": !onClick } ); + const isDisabled = !wallet; + return ( diff --git a/apps/namadillo/src/App/Transfer/SelectedWallet.tsx b/apps/namadillo/src/App/Transfer/SelectedWallet.tsx new file mode 100644 index 000000000..d73d8189f --- /dev/null +++ b/apps/namadillo/src/App/Transfer/SelectedWallet.tsx @@ -0,0 +1,61 @@ +import { integrations } from "@namada/integrations"; +import { shortenAddress } from "@namada/utils"; +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { WalletProvider } from "types"; + +type SelectedWalletProps = { + wallet: WalletProvider; + onClick?: () => void; + isShielded?: boolean; +}; + +export const SelectedWallet = ({ + wallet, + onClick, + isShielded, +}: SelectedWalletProps): JSX.Element => { + const [walletAddress, setWalletAddress] = useState(""); + + const loadAccounts = async (): Promise => { + try { + const integration = integrations[wallet.id]; + integration.detect(); + await integration.connect(); + const accounts = await integration.accounts(); + + if (accounts && accounts.length > 0) { + if (wallet.id === "namada" && isShielded && accounts.length > 1) { + setWalletAddress(accounts[1].address); + return; + } + setWalletAddress(accounts[0].address); + } + } catch { + // TODO: handle error catching + } + }; + + useEffect(() => { + loadAccounts(); + }, [isShielded]); + + return ( +
    + {walletAddress && shortenAddress(walletAddress, 8, 6)} + {wallet.name +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/Transfer.tsx b/apps/namadillo/src/App/Transfer/Transfer.tsx index 5d8ec7647..da468cce2 100644 --- a/apps/namadillo/src/App/Transfer/Transfer.tsx +++ b/apps/namadillo/src/App/Transfer/Transfer.tsx @@ -1,5 +1,5 @@ import { Route, Routes } from "react-router-dom"; -import { IBCTransfers } from "./IBCTransfers"; +import { Example } from "./Example"; import { MaspOverview } from "./MaspOverview"; import { NamTransfer } from "./NamTransfer"; import { Shield } from "./Shield"; @@ -22,10 +22,7 @@ export const Transfer: React.FC = () => ( path={TransferRoutes.shieldAll().toString()} element={} /> - } - /> + } /> ); diff --git a/apps/namadillo/src/App/Transfer/TransferArrow.tsx b/apps/namadillo/src/App/Transfer/TransferArrow.tsx new file mode 100644 index 000000000..ff7158087 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/TransferArrow.tsx @@ -0,0 +1,26 @@ +type TransferArrowProps = { + color: string; +}; + +export const TransferArrow = ({ color }: TransferArrowProps): JSX.Element => ( + + + + + +); diff --git a/apps/namadillo/src/App/Transfer/TransferDestination.tsx b/apps/namadillo/src/App/Transfer/TransferDestination.tsx index a9f454622..47c29d66f 100644 --- a/apps/namadillo/src/App/Transfer/TransferDestination.tsx +++ b/apps/namadillo/src/App/Transfer/TransferDestination.tsx @@ -1,21 +1,24 @@ +import { Chain } from "@chain-registry/types"; import { NamCurrency } from "App/Common/NamCurrency"; import { TabSelector } from "App/Common/TabSelector"; import BigNumber from "bignumber.js"; import clsx from "clsx"; -import { Chain, Provider } from "types"; +import { WalletProvider } from "types"; import namadaShieldedSvg from "./assets/namada-shielded.svg"; import namadaTransparentSvg from "./assets/namada-transparent.svg"; import { CustomAddressForm } from "./CustomAddressForm"; import { SelectedChain } from "./SelectedChain"; +import { SelectedWallet } from "./SelectedWallet"; type TransferDestinationProps = { isShielded?: boolean; onChangeShielded?: (isShielded: boolean) => void; chain?: Chain; - provider?: Provider; + wallet?: WalletProvider; className?: string; transactionFee?: BigNumber; customAddressActive?: boolean; + openChainSelector?: () => void; onToggleCustomAddress?: (isActive: boolean) => void; onChangeAddress?: (address: string | undefined) => void; address?: string; @@ -27,19 +30,22 @@ const parseChainInfo = ( chain?: Chain, isShielded?: boolean ): Chain | undefined => { - if (chain?.name !== "Namada") { + if (chain?.chain_name !== "namada") { return chain; } return { ...chain, - name: isShielded ? "Namada Shielded" : "Namada Transparent", - iconUrl: isShielded ? namadaShieldedSvg : namadaTransparentSvg, + pretty_name: isShielded ? "Namada Shielded" : "Namada Transparent", + logo_URIs: { + ...chain.logo_URIs, + svg: isShielded ? namadaShieldedSvg : namadaTransparentSvg, + }, }; }; export const TransferDestination = ({ chain, - provider, + wallet, isShielded, onChangeShielded, transactionFee, @@ -49,22 +55,31 @@ export const TransferDestination = ({ onChangeAddress, memo, onChangeMemo, + openChainSelector, }: TransferDestinationProps): JSX.Element => { return (
    - {onChangeShielded && chain?.name === "Namada" && ( - onChangeShielded(!isShielded)} - /> + {onChangeShielded && chain?.chain_name === "namada" && ( + )} {onToggleCustomAddress && ( @@ -78,10 +93,15 @@ export const TransferDestination = ({ /> )} - +
    + + {wallet && } +
    {customAddressActive && ( +
    Transaction Fee
    diff --git a/apps/namadillo/src/App/Transfer/TransferModule.tsx b/apps/namadillo/src/App/Transfer/TransferModule.tsx index 04d198e97..35658dc7f 100644 --- a/apps/namadillo/src/App/Transfer/TransferModule.tsx +++ b/apps/namadillo/src/App/Transfer/TransferModule.tsx @@ -1,63 +1,110 @@ -import { Stack } from "@namada/components"; +import { Asset, Chain, Chains } from "@chain-registry/types"; +import { ActionButton, Stack } from "@namada/components"; import BigNumber from "bignumber.js"; import { useState } from "react"; -import { Asset, Chain } from "types"; -import { SelectProviderModal } from "./SelectProviderModal"; +import { WalletProvider } from "types"; +import { SelectAssetModal } from "./SelectAssetModal"; +import { SelectChainModal } from "./SelectChainModal"; +import { SelectWalletModal } from "./SelectWalletModal"; +import { TransferArrow } from "./TransferArrow"; import { TransferDestination } from "./TransferDestination"; import { TransferSource } from "./TransferSource"; type TransferModuleProps = { isConnected: boolean; + availableAmount?: BigNumber; + availableWallets: WalletProvider[]; + onSubmitTransfer: () => void; + onChangeWallet?: (wallet: WalletProvider) => void; + sourceWallet?: WalletProvider; + availableSourceChains?: Chains; sourceChain?: Chain; - onChangeSourceChain?: () => void; + onChangeSourceChain?: (chain: Chain) => void; + availableDestinationChains?: Chains; destinationChain?: Chain; + destinationWallet?: WalletProvider; onChangeDestinationChain?: (chain: Chain) => void; selectedAsset?: Asset; + availableAssets?: Asset[]; onChangeSelectedAsset?: (asset: Asset | undefined) => void; - amount?: BigNumber; - onChangeAmount?: (amount: BigNumber) => void; isShielded?: boolean; onChangeShielded?: (isShielded: boolean) => void; enableCustomAddress?: boolean; - onSubmitTransfer: () => void; + transactionFee?: BigNumber; }; export const TransferModule = ({ isConnected, selectedAsset, + availableAssets, + onChangeSelectedAsset, + availableSourceChains, sourceChain, + onChangeSourceChain, + availableDestinationChains, destinationChain, + destinationWallet, + onChangeDestinationChain, isShielded, onChangeShielded, enableCustomAddress, + onChangeWallet, + availableWallets, + sourceWallet, + availableAmount, + transactionFee, }: TransferModuleProps): JSX.Element => { const [providerSelectorModalOpen, setProviderSelectorModalOpen] = useState(false); - const [chainSelectorModalOpen, setChainSelectorModalOpen] = useState(false); + const [sourceChainModalOpen, setSourceChainModalOpen] = useState(false); + const [destinationChainModalOpen, setDestinationChainModalOpen] = + useState(false); const [assetSelectorModalOpen, setAssetSelectorModalOpen] = useState(false); const [customAddressActive, setCustomAddressActive] = useState(false); const [memo, setMemo] = useState(""); const [customAddress, setCustomAddress] = useState(""); - const [amount, setAmount] = useState(new BigNumber(0)); + const [amount, setAmount] = useState(new BigNumber(0)); + + const validateTransfer = (): boolean => { + if (!amount || amount.eq(0)) return false; + if (!sourceWallet || !sourceChain || !selectedAsset) return false; + if (!destinationWallet || !destinationChain) return false; + if ( + !availableAmount || + availableAmount.lt(amount.plus(transactionFee || 0)) + ) { + return false; + } + return true; + }; + + const onSubmit = (e: React.FormEvent): void => { + // TODO: implement submit + e.preventDefault(); + }; return ( <>
    - + setProviderSelectorModalOpen(true)} - openChainSelector={() => setChainSelectorModalOpen(true)} + openChainSelector={() => setSourceChainModalOpen(true)} openAssetSelector={() => setAssetSelectorModalOpen(true)} amount={amount} - onChangeAmount={(e) => - setAmount(e.target.value || new BigNumber(0)) - } + availableAmount={availableAmount} + onChangeAmount={setAmount} /> + + + - {chainSelectorModalOpen &&
    } - {assetSelectorModalOpen &&
    } + + {sourceWallet ? "Submit" : "Select Wallet"} +
    - {providerSelectorModalOpen && ( - setProviderSelectorModalOpen(false)} - onConnect={() => {}} + onConnect={onChangeWallet} + /> + )} + {sourceChainModalOpen && onChangeSourceChain && sourceWallet && ( + setSourceChainModalOpen(false)} + chains={availableSourceChains || []} + onSelect={onChangeSourceChain} + /> + )} + {destinationChainModalOpen && + onChangeDestinationChain && + destinationWallet && ( + setDestinationChainModalOpen(false)} + chains={availableDestinationChains || []} + onSelect={onChangeDestinationChain} + /> + )} + {assetSelectorModalOpen && onChangeSelectedAsset && sourceWallet && ( + setAssetSelectorModalOpen(false)} + assets={availableAssets || []} + onSelect={onChangeSelectedAsset} /> )} diff --git a/apps/namadillo/src/App/Transfer/TransferSource.tsx b/apps/namadillo/src/App/Transfer/TransferSource.tsx index 3e52ca813..b4c415b73 100644 --- a/apps/namadillo/src/App/Transfer/TransferSource.tsx +++ b/apps/namadillo/src/App/Transfer/TransferSource.tsx @@ -1,45 +1,52 @@ -import { AmountInput, ChangeAmountEvent } from "@namada/components"; +import { Asset, Chain } from "@chain-registry/types"; +import { AmountInput } from "@namada/components"; import BigNumber from "bignumber.js"; import clsx from "clsx"; -import { Asset, Chain, Provider } from "types"; +import { WalletProvider } from "types"; import { AvailableAmountFooter } from "./AvailableAmountFooter"; import { ConnectProviderButton } from "./ConnectProviderButton"; import { SelectedAsset } from "./SelectedAsset"; import { SelectedChain } from "./SelectedChain"; +import { SelectedWallet } from "./SelectedWallet"; export type TransferSourceProps = { isConnected: boolean; - provider?: Provider; + wallet?: WalletProvider; asset?: Asset; chain?: Chain; openChainSelector?: () => void; openAssetSelector?: () => void; openProviderSelector?: () => void; amount?: BigNumber; - onChangeAmount?: ChangeAmountEvent; + availableAmount?: BigNumber; + onChangeAmount?: (amount: BigNumber | undefined) => void; }; export const TransferSource = ({ chain, asset, - provider, + wallet, openProviderSelector, openChainSelector, openAssetSelector, amount, + availableAmount, onChangeAmount, }: TransferSourceProps): JSX.Element => { return (
    -
    +
    - {!provider && } + {!wallet && } + {wallet && ( + + )}
    -
    +
    onChangeAmount && onChangeAmount(e.target.value)} />
    -
    - -
    + {asset && availableAmount && ( +
    + onChangeAmount && onChangeAmount(availableAmount)} + /> +
    + )}
    ); }; diff --git a/apps/namadillo/src/App/Transfer/WalletCard.tsx b/apps/namadillo/src/App/Transfer/WalletCard.tsx new file mode 100644 index 000000000..931e6973b --- /dev/null +++ b/apps/namadillo/src/App/Transfer/WalletCard.tsx @@ -0,0 +1,50 @@ +import { GoLinkExternal } from "react-icons/go"; +import { WalletProvider } from "types"; + +type WalletCardProps = { + wallet: WalletProvider; + installed: boolean; + connected: boolean; + onConnect?: () => void; + onSelect?: () => void; +}; + +export const WalletCard = ({ + wallet, + installed, + connected, + onConnect, + onSelect, +}: WalletCardProps): JSX.Element => { + const getDownloadUrl = (): string => { + if (/firefox/i.test(navigator.userAgent)) { + return wallet.downloadUrl.firefox; + } + return wallet.downloadUrl.chrome; + }; + + return ( +
    + + + {wallet.name} + + + {!installed && ( + + Install + + )} + {installed && !connected && ( + + )} + {installed && connected && } + +
    + ); +}; diff --git a/apps/namadillo/src/App/Transfer/__mocks__/assets.tsx b/apps/namadillo/src/App/Transfer/__mocks__/assets.tsx new file mode 100644 index 000000000..bbd2c859d --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__mocks__/assets.tsx @@ -0,0 +1,19 @@ +import { Asset } from "@chain-registry/types"; + +export const assetMock: Partial = { + name: "Ethereum", + symbol: "ETH", + logo_URIs: { + svg: "https://example.com/eth-icon.png", + }, +}; + +export const assetMock2: Partial = { + name: "Bitcoin", + symbol: "BTC", + logo_URIs: { svg: "btc.svg" }, +}; + +export const assetMockList: Array> = [assetMock, assetMock2]; + +export const assetWithoutLogo: Partial = { ...assetMock, logo_URIs: {} }; diff --git a/apps/namadillo/src/App/Transfer/__mocks__/chains.ts b/apps/namadillo/src/App/Transfer/__mocks__/chains.ts index da2ad5502..cb3b49c35 100644 --- a/apps/namadillo/src/App/Transfer/__mocks__/chains.ts +++ b/apps/namadillo/src/App/Transfer/__mocks__/chains.ts @@ -1,13 +1,15 @@ -import { Chain } from "types"; +import { Chain } from "@chain-registry/types"; -export const namadaChainMock: Chain = { - chainId: "test", - name: "Namada", - iconUrl: "namada-icon", +export const namadaChainMock: Partial = { + chain_id: "namada-mock-chain-id", + chain_name: "namada", + pretty_name: "Namada", + logo_URIs: { svg: "namada-icon" }, }; -export const randomChainMock: Chain = { - chainId: "test", - name: "TestChain", - iconUrl: "testchain-icon", +export const randomChainMock: Partial = { + chain_id: "testchain-mock-chain-id", + chain_name: "testchain", + pretty_name: "TestChain", + logo_URIs: { svg: "testchain-icon" }, }; diff --git a/apps/namadillo/src/App/Transfer/__mocks__/providers.ts b/apps/namadillo/src/App/Transfer/__mocks__/providers.ts index af2cd0157..32d34c5cd 100644 --- a/apps/namadillo/src/App/Transfer/__mocks__/providers.ts +++ b/apps/namadillo/src/App/Transfer/__mocks__/providers.ts @@ -1,12 +1,11 @@ -import { Provider } from "types"; +import { WalletProvider } from "types"; -export const providerMock: Provider = { +export const walletMock: WalletProvider = { + id: "keplr", name: "Keplr", iconUrl: "test.svg", - connected: false, -}; - -export const providerConnectedMock: Provider = { - ...providerMock, - connected: true, + downloadUrl: { + chrome: "https://google.com", + firefox: "https://mozilla.org", + }, }; diff --git a/apps/namadillo/src/App/Transfer/__tests__/AssetCard.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/AssetCard.test.tsx new file mode 100644 index 000000000..813bfd155 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/AssetCard.test.tsx @@ -0,0 +1,23 @@ +import { Asset } from "@chain-registry/types"; +import { render, screen } from "@testing-library/react"; +import { AssetCard } from "App/Transfer/AssetCard"; +import { assetMock, assetWithoutLogo } from "App/Transfer/__mocks__/assets"; + +describe("Component: AssetCard", () => { + it("should render asset name", () => { + render(); + expect(screen.getByText("Ethereum")).toBeInTheDocument(); + }); + + it("should render asset logo if available", () => { + render(); + const logo = screen.getByAltText("Ethereum logo"); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute("src", assetMock.logo_URIs?.svg); + }); + + it("should render placeholder if logo is not available", () => { + render(); + expect(screen.getByAltText(/logo not available/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/ChainCard.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/ChainCard.test.tsx new file mode 100644 index 000000000..42c2da8fd --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/ChainCard.test.tsx @@ -0,0 +1,18 @@ +import { Chain } from "@chain-registry/types"; +import { render, screen } from "@testing-library/react"; +import { ChainCard } from "App/Transfer/ChainCard"; +import { randomChainMock } from "../__mocks__/chains"; + +describe("Component: ChainCard", () => { + it("renders the chain's name", () => { + render(); + expect(screen.getByText(randomChainMock.pretty_name!)).toBeInTheDocument(); + }); + + it("renders the chain's logo", () => { + render(); + const logo = screen.getByAltText(`${randomChainMock.pretty_name!} logo`); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute("src", randomChainMock.logo_URIs?.svg); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/SelectAssetModal.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/SelectAssetModal.test.tsx new file mode 100644 index 000000000..dc41cce87 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/SelectAssetModal.test.tsx @@ -0,0 +1,64 @@ +import { Asset } from "@chain-registry/types"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SelectAssetModal } from "App/Transfer/SelectAssetModal"; +import { assetMockList } from "../__mocks__/assets"; + +describe("SelectAssetModal", () => { + const onCloseMock = jest.fn(); + const onSelectMock = jest.fn(); + + it("should render the modal title", () => { + render( + + ); + expect(screen.getByText("Select Asset")).toBeInTheDocument(); + }); + + it("should render all assets", () => { + render( + + ); + expect(screen.getByText("Bitcoin")).toBeInTheDocument(); + expect(screen.getByText("Ethereum")).toBeInTheDocument(); + }); + + it("should filter assets based on search input", async () => { + render( + + ); + fireEvent.change(screen.getByPlaceholderText(/search/i, { exact: false }), { + target: { value: "Bit" }, + }); + + // Event is debounced + waitFor(() => { + expect(screen.getByText("Bitcoin")).toBeInTheDocument(); + expect(screen.queryByText("Ethereum")).not.toBeInTheDocument(); + }); + }); + + it("should call onSelect and onClose when an asset is selected", () => { + render( + + ); + fireEvent.click(screen.getByText("Bitcoin")); + expect(onSelectMock).toHaveBeenCalledWith(assetMockList[1]); + expect(onCloseMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/SelectChainModal.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/SelectChainModal.test.tsx new file mode 100644 index 000000000..03d399692 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/SelectChainModal.test.tsx @@ -0,0 +1,60 @@ +import { Chains } from "@chain-registry/types"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { + namadaChainMock, + randomChainMock, +} from "App/Transfer/__mocks__/chains"; +import { SelectChainModal } from "App/Transfer/SelectChainModal"; + +describe("Component: SelectChainModal", () => { + const mockChains = [randomChainMock, namadaChainMock]; + + it("should render component and list of chains correctly", () => { + render( + + ); + expect(screen.getByText("Namada")).toBeInTheDocument(); + expect(screen.getByText("TestChain")).toBeInTheDocument(); + // + // Check for modal title + expect(screen.getByText("Select Source Chain")).toBeInTheDocument(); + + // Check if all chains are rendered + mockChains.forEach((chain) => { + expect(screen.getByText(chain.pretty_name!)).toBeInTheDocument(); + expect( + screen.getByAltText(`${chain.pretty_name} logo`) + ).toBeInTheDocument(); + }); + }); + + it("should select the correct chain on click", () => { + const handleSelect = jest.fn(); + render( + + ); + fireEvent.click(screen.getByText(mockChains[0].pretty_name!)); + expect(handleSelect).toHaveBeenCalledWith(mockChains[0]); + }); + + it("should display warning message if no chains were provided", () => { + const handleSelect = jest.fn(); + render( + + ); + expect(screen.getByText(/no available chains/i, { exact: false })) + .toBeInTheDocument; + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx index 5ebe93cc1..c60b8f3a1 100644 --- a/apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx +++ b/apps/namadillo/src/App/Transfer/__tests__/SelectedAsset.test.tsx @@ -1,21 +1,11 @@ +import { Asset, Chain } from "@chain-registry/types"; import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { SelectedAsset } from "App/Transfer/SelectedAsset"; // Adjust the path accordingly -import { Asset, Chain } from "types"; // Adjust the path accordingly +import { assetMock } from "../__mocks__/assets"; +import { randomChainMock } from "../__mocks__/chains"; describe("SelectedAsset", () => { - const mockChain: Chain = { - chainId: "1", - name: "Ethereum", - iconUrl: "https://example.com/ethereum-icon.png", - }; - - const mockAsset: Partial = { - name: "Ethereum", - denomination: "ETH", - iconUrl: "https://example.com/eth-icon.png", - }; - it("renders with no chain and disables the button", () => { render(); const button = screen.getByRole("button"); @@ -24,7 +14,7 @@ describe("SelectedAsset", () => { it("renders with no asset selected", () => { const mockFn = jest.fn(); - render(); + render(); const button = screen.getByRole("button"); expect(button).toBeEnabled(); @@ -40,8 +30,8 @@ describe("SelectedAsset", () => { const handleClick = jest.fn(); render( ); @@ -49,13 +39,11 @@ describe("SelectedAsset", () => { const button = screen.getByRole("button"); expect(button).toBeEnabled(); - const assetDenomination = screen.getByText(mockAsset.denomination!); + const assetDenomination = screen.getByText(assetMock.symbol!); expect(assetDenomination).toBeInTheDocument(); - const assetImage = screen.getByAltText(`${mockAsset.name} image`); - expect(assetImage).toHaveStyle( - `background-image: url(${mockAsset.iconUrl})` - ); + const assetImage = screen.getByAltText(`${assetMock.name} image`); + expect(assetImage).toHaveAttribute("src", assetMock.logo_URIs?.svg); fireEvent.click(button); expect(handleClick).toHaveBeenCalledTimes(1); diff --git a/apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx index 9008be967..d00eae5cf 100644 --- a/apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx +++ b/apps/namadillo/src/App/Transfer/__tests__/SelectedChain.test.tsx @@ -1,16 +1,11 @@ +import { Chain } from "@chain-registry/types"; import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { SelectedChain } from "App/Transfer/SelectedChain"; -import { Chain } from "types"; -import { providerConnectedMock } from "../__mocks__/providers"; +import { randomChainMock } from "../__mocks__/chains"; +import { walletMock } from "../__mocks__/providers"; describe("Component: SelectedChain", () => { - const mockChain: Chain = { - chainId: "chain-id", - name: "Ethereum", - iconUrl: "https://example.com/ethereum-icon.png", - }; - it("renders disabled with no provider selected", () => { render(); const button = screen.getByRole("button"); @@ -18,14 +13,14 @@ describe("Component: SelectedChain", () => { }); it("renders empty when chain is passed, but provider is disconnected", () => { - render(); + render(); const button = screen.getByRole("button"); expect(button).toBeDisabled(); expect(button.getAttribute("aria-description")).toMatch(/no chain/i); }); it("renders correctly with no chain selected", () => { - render(); + render(); const button = screen.getByRole("button"); expect(button).toBeInTheDocument(); expect(button).toBeEnabled(); @@ -34,31 +29,28 @@ describe("Component: SelectedChain", () => { it("renders correctly with chain selected", () => { render( - + ); const button = screen.getByRole("button"); expect(button).toBeInTheDocument(); - expect(button.getAttribute("aria-description")).toContain(mockChain.name); + expect(button.getAttribute("aria-description")).toContain( + randomChainMock.pretty_name + ); - const chainName = screen.getByText(mockChain.name); + const chainName = screen.getByText(randomChainMock.pretty_name!); expect(chainName).toBeInTheDocument(); - const chainImage = screen.getByAltText(`${mockChain.name}`, { + const chainImage = screen.getByAltText(`${randomChainMock.pretty_name}`, { exact: false, }); expect(chainImage).toBeInTheDocument(); - expect(chainImage).toHaveAttribute( - "style", - `background-image: url(${mockChain.iconUrl});` - ); + expect(chainImage).toHaveAttribute("src", randomChainMock.logo_URIs?.svg); }); it("calls onClick when the component is clicked", () => { const handleClick = jest.fn(); - render( - - ); + render(); const button = screen.getByRole("button"); fireEvent.click(button); diff --git a/apps/namadillo/src/App/Transfer/__tests__/SelectedWallet.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/SelectedWallet.test.tsx new file mode 100644 index 000000000..33ed1f800 --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/SelectedWallet.test.tsx @@ -0,0 +1,82 @@ +import { walletMock } from "../__mocks__/providers"; +const testWalletAddress = "0x1234567890abcdefghijkl"; +const mockIntegration = { + detect: jest.fn(), + connect: jest.fn(), + accounts: jest.fn().mockResolvedValue([{ address: testWalletAddress }]), +}; + +// Avoid hoisting +(() => { + jest.mock("@namada/integrations", () => ({ + integrations: { + [walletMock.id]: mockIntegration, + }, + })); +})(); + +import { shortenAddress } from "@namada/utils"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { SelectedWallet } from "App/Transfer/SelectedWallet"; + +describe("Component: SelectedWallet", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render the wallet icon", async () => { + await act(async () => { + render(); + }); + const walletIcon = screen.getByAltText(/logo/i, { exact: false }); + expect(walletIcon).toBeInTheDocument(); + expect(walletIcon).toHaveAttribute("src", walletMock.iconUrl); + }); + + it("should display the shortened wallet address after loading accounts", async () => { + render(); + + // Wait for the address to be loaded + await waitFor(() => { + expect(mockIntegration.connect).toHaveBeenCalled(); + expect(mockIntegration.accounts).toHaveBeenCalled(); + }); + + // Check if the address is correctly shortened + const shortenedAddress = shortenAddress(testWalletAddress, 8, 6); + expect(screen.getByText(shortenedAddress)).toBeInTheDocument(); + }); + + it("should trigger the onClick function when clicked", async () => { + const onClickMock = jest.fn(); + await act(async () => { + render(); + }); + const walletButton = screen.getByRole("button"); + fireEvent.click(walletButton); + expect(onClickMock).toHaveBeenCalled(); + }); + + it("should handle missing wallet address gracefully", async () => { + //Mock integration to return no accounts + mockIntegration.accounts.mockResolvedValue([]); + + await act(async () => { + render(); + }); + + // Wait for the component to try loading accounts + await waitFor(() => { + expect(mockIntegration.accounts).toHaveBeenCalled(); + }); + + // Check that no address is displayed + expect(screen.queryByText(/0x/i)).toBeNull(); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx index fee5a7878..6cd71963d 100644 --- a/apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx +++ b/apps/namadillo/src/App/Transfer/__tests__/TransferDestination.test.tsx @@ -1,3 +1,4 @@ +import { Chain } from "@chain-registry/types"; import { fireEvent, render, screen } from "@testing-library/react"; import { namadaChainMock, @@ -5,7 +6,7 @@ import { } from "App/Transfer/__mocks__/chains"; import { TransferDestination } from "App/Transfer/TransferDestination"; import BigNumber from "bignumber.js"; -import { providerMock } from "../__mocks__/providers"; +import { walletMock } from "../__mocks__/providers"; describe("TransferDestination", () => { it("should render the component with the default props", () => { @@ -18,7 +19,7 @@ describe("TransferDestination", () => { ); expect(screen.getByText("Shielded")).toBeInTheDocument(); @@ -34,7 +35,7 @@ describe("TransferDestination", () => { render( ); @@ -45,8 +46,8 @@ describe("TransferDestination", () => { render( ); expect(screen.getByText(/namada shielded/i)).toBeInTheDocument(); @@ -56,8 +57,8 @@ describe("TransferDestination", () => { render( ); expect(screen.getByText(/namada transparent/i)).toBeInTheDocument(); @@ -68,7 +69,7 @@ describe("TransferDestination", () => { render( ); diff --git a/apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx index b21c3c2c0..4c0413616 100644 --- a/apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx +++ b/apps/namadillo/src/App/Transfer/__tests__/TransferSource.test.tsx @@ -1,3 +1,4 @@ +import { Chain } from "@chain-registry/types"; import { fireEvent, render, screen } from "@testing-library/react"; import { TransferSource, @@ -5,7 +6,7 @@ import { } from "App/Transfer/TransferSource"; import BigNumber from "bignumber.js"; import { namadaChainMock } from "../__mocks__/chains"; -import { providerConnectedMock } from "../__mocks__/providers"; +import { walletMock } from "../__mocks__/providers"; describe("Component: TransferSource", () => { it("should render the component with the default props", () => { @@ -45,7 +46,7 @@ describe("Component: TransferSource", () => { const openChainSelectorMock = jest.fn(); setup({ openChainSelector: openChainSelectorMock, - provider: providerConnectedMock, + wallet: walletMock, }); const chain = getEmptyChain(); fireEvent.click(chain); @@ -64,7 +65,10 @@ describe("Component: TransferSource", () => { it("should call openAssetSelector when the SelectedAsset is clicked", () => { const openAssetSelectorMock = jest.fn(); - setup({ openAssetSelector: openAssetSelectorMock, chain: namadaChainMock }); + setup({ + openAssetSelector: openAssetSelectorMock, + chain: namadaChainMock as Chain, + }); const assetControl = getEmptyAsset(); fireEvent.click(assetControl); expect(openAssetSelectorMock).toHaveBeenCalled(); diff --git a/apps/namadillo/src/App/Transfer/__tests__/WalletCard.test.tsx b/apps/namadillo/src/App/Transfer/__tests__/WalletCard.test.tsx new file mode 100644 index 000000000..9f0cc6cda --- /dev/null +++ b/apps/namadillo/src/App/Transfer/__tests__/WalletCard.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { WalletCard } from "App/Transfer/WalletCard"; +import { walletMock } from "../__mocks__/providers"; + +describe("Component: WalletCard", () => { + it("should render wallet name and icon", () => { + render( + + ); + + expect(screen.getByText("Keplr")).toBeInTheDocument(); + expect(screen.getByRole("img")).toHaveAttribute("src", walletMock.iconUrl); + }); + + const mockUserAgent = (userAgent: string): void => { + const fn = jest.spyOn(navigator, "userAgent", "get"); + fn.mockReturnValue(userAgent); + }; + + it("should show 'Install' with correct link if wallet is not installed", () => { + const chromeUserAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36`; + const firefoxUserAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0`; + + // Render Chrome url + mockUserAgent(chromeUserAgent); + render( + + ); + const chromeLink = screen.getByText(/install/i); + expect(chromeLink).toBeInTheDocument(); + expect(chromeLink).toHaveAttribute("href", walletMock.downloadUrl.chrome); + + // Render firefox url + cleanup(); + jest.clearAllMocks(); + mockUserAgent(firefoxUserAgent); + render( + + ); + const firefoxLink = screen.getByText(/install/i); + expect(firefoxLink).toHaveAttribute("href", walletMock.downloadUrl.firefox); + }); + + it("should show 'Connect' button if wallet is installed but not connected", () => { + const onConnectMock = jest.fn(); + render( + + ); + + const connectButton = screen.getByText(/connect/i); + expect(connectButton).toBeInTheDocument(); + + fireEvent.click(connectButton); + expect(onConnectMock).toHaveBeenCalled(); + }); + + it("should show 'Select' button if wallet is installed and connected", () => { + const onSelectMock = jest.fn(); + render( + + ); + + const selectButton = screen.getByText(/select/i); + expect(selectButton).toBeInTheDocument(); + + fireEvent.click(selectButton); + expect(onSelectMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/namadillo/src/App/Transfer/routes.ts b/apps/namadillo/src/App/Transfer/routes.ts index 74c7611f8..6f7f44839 100644 --- a/apps/namadillo/src/App/Transfer/routes.ts +++ b/apps/namadillo/src/App/Transfer/routes.ts @@ -14,7 +14,7 @@ export const shield = (): RouteOutput => routeOutput("/shield"); export const shieldAll = (): RouteOutput => routeOutput(`/shield-all`); -export const ibcTransfer = (): RouteOutput => routeOutput(`/ibc`); +export const example = (): RouteOutput => routeOutput(`/example`); export default { index, @@ -23,5 +23,5 @@ export default { namTransfer, shield, shieldAll, - ibcTransfer, + example, }; diff --git a/apps/namadillo/src/atoms/integrations/atoms.ts b/apps/namadillo/src/atoms/integrations/atoms.ts new file mode 100644 index 000000000..32aa88691 --- /dev/null +++ b/apps/namadillo/src/atoms/integrations/atoms.ts @@ -0,0 +1,13 @@ +import { ExtensionKey } from "@namada/types"; +import { atomWithStorage } from "jotai/utils"; + +// Currently we're just integrating with Keplr, but in the future we might use different wallets +export const selectedIBCWallet = atomWithStorage( + "namadillo:ibc:wallet", + undefined +); + +export const selectedIBCChainAtom = atomWithStorage( + "namadillo:ibc:chainId", + undefined +); diff --git a/apps/namadillo/src/atoms/integrations/index.ts b/apps/namadillo/src/atoms/integrations/index.ts new file mode 100644 index 000000000..4e0d46d9a --- /dev/null +++ b/apps/namadillo/src/atoms/integrations/index.ts @@ -0,0 +1 @@ +export * from "./atoms"; diff --git a/apps/namadillo/src/hooks/useExtensionConnect.ts b/apps/namadillo/src/hooks/useExtensionConnect.ts index 237eb7712..1e5b7d781 100644 --- a/apps/namadillo/src/hooks/useExtensionConnect.ts +++ b/apps/namadillo/src/hooks/useExtensionConnect.ts @@ -1,5 +1,5 @@ import { useIntegrationConnection } from "@namada/integrations"; -import { ChainKey } from "@namada/types"; +import { ExtensionKey } from "@namada/types"; import { ConnectStatus, namadaExtensionConnectionStatus } from "atoms/settings"; import { useAtom } from "jotai"; import { useEffect } from "react"; @@ -11,7 +11,7 @@ type UseConnectOutput = { }; export const useExtensionConnect = ( - chainKey: ChainKey = "namada" + chainKey: ExtensionKey = "namada" ): UseConnectOutput => { const [connectionStatus, setConnectionStatus] = useAtom( namadaExtensionConnectionStatus diff --git a/apps/namadillo/src/integrations/assets/keplr.svg b/apps/namadillo/src/integrations/assets/keplr.svg new file mode 100644 index 000000000..e56c5257c --- /dev/null +++ b/apps/namadillo/src/integrations/assets/keplr.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/namadillo/src/integrations/assets/namada.svg b/apps/namadillo/src/integrations/assets/namada.svg new file mode 100644 index 000000000..7f4e2a7fa --- /dev/null +++ b/apps/namadillo/src/integrations/assets/namada.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/namadillo/src/integrations/index.ts b/apps/namadillo/src/integrations/index.ts new file mode 100644 index 000000000..a2604356d --- /dev/null +++ b/apps/namadillo/src/integrations/index.ts @@ -0,0 +1,28 @@ +import { ExtensionKey } from "@namada/types"; +import { WalletProvider } from "types"; +import keplrSvg from "./assets/keplr.svg"; +import namadaSvg from "./assets/namada.svg"; + +export const wallets: Partial> = { + keplr: { + id: "keplr", + name: "Keplr", + iconUrl: keplrSvg, + downloadUrl: { + chrome: + "https://chromewebstore.google.com/detail/keplr/dmkamcknogkgcdfhhbddcghachkejeap", + firefox: "https://addons.mozilla.org/en-US/firefox/addon/keplr/", + }, + }, + + namada: { + id: "namada", + name: "Namada", + iconUrl: namadaSvg, + downloadUrl: { + chrome: + "https://chromewebstore.google.com/detail/namada-keychain/hnebcbhjpeejiclgbohcijljcnjdofek", + firefox: "", + }, + }, +}; diff --git a/apps/namadillo/src/lib/query.ts b/apps/namadillo/src/lib/query.ts index a2cb524a8..b7b26cd93 100644 --- a/apps/namadillo/src/lib/query.ts +++ b/apps/namadillo/src/lib/query.ts @@ -2,6 +2,7 @@ import { getIntegration } from "@namada/integrations"; import { Account, AccountType, + ExtensionKey, Signer, TxMsgValue, TxProps, @@ -138,11 +139,11 @@ export const buildTx = async ( * Asynchronously signs an encoded batch transaction using Namada extension. */ export const signTx = async ( - chain: ChainSettings, + wallet: ExtensionKey, typedEncodedTx: EncodedTxData, owner: string ): Promise => { - const integration = getIntegration(chain.id); + const integration = getIntegration(wallet); const signingClient = integration.signer() as Signer; const store = getDefaultStore(); @@ -190,7 +191,7 @@ export const buildTxPair = async ( queryProps, txFn ); - const signedTxs = await signTx(chain, encodedTxData, owner); + const signedTxs = await signTx(chain.extensionId, encodedTxData, owner); return { signedTxs, encodedTxData, diff --git a/apps/namadillo/src/registry/index.ts b/apps/namadillo/src/registry/index.ts index 86b83d5cf..d108a645f 100644 --- a/apps/namadillo/src/registry/index.ts +++ b/apps/namadillo/src/registry/index.ts @@ -1,5 +1,5 @@ import cosmoshub from "./cosmoshub.json"; -import namada from "./namada.json"; +import namada from "./namada-temp.json"; type MinimalDenom = string; type ConfigName = string; diff --git a/apps/namadillo/src/registry/namada-temp.json b/apps/namadillo/src/registry/namada-temp.json new file mode 100644 index 000000000..7ec080145 --- /dev/null +++ b/apps/namadillo/src/registry/namada-temp.json @@ -0,0 +1,22 @@ +{ + "chainName": "Namada", + "currencies": [ + { + "coinDecimals": 6, + "coinDenom": "NAM", + "coinMinimalDenom": "namnam" + } + ], + "feeCurrencies": [ + { + "coinDecimals": 6, + "coinDenom": "NAM", + "coinMinimalDenom": "namnam" + } + ], + "stakeCurrency": { + "coinDecimals": 6, + "coinDenom": "NAM", + "coinMinimalDenom": "namnam" + } +} diff --git a/apps/namadillo/src/registry/namada.json b/apps/namadillo/src/registry/namada.json index 7ec080145..60f075611 100644 --- a/apps/namadillo/src/registry/namada.json +++ b/apps/namadillo/src/registry/namada.json @@ -1,22 +1,101 @@ { - "chainName": "Namada", - "currencies": [ - { - "coinDecimals": 6, - "coinDenom": "NAM", - "coinMinimalDenom": "namnam" + "chain_name": "namada", + "status": "live", + "network_type": "mainnet", + "website": "https://namada.net/", + "update_link": "", + "pretty_name": "Namada", + "chain_type": "namada", + "chain_id": "namada-1", + "bech32_prefix": "nam", + "daemon_name": "nam", + "node_home": "", + "key_algos": ["secp256k1"], + "slip44": 118, + "fees": { + "fee_tokens": [ + { + "denom": "namnam", + "fixed_min_gas_price": 0.0025, + "low_gas_price": 0.0025, + "average_gas_price": 0.025, + "high_gas_price": 0.04 + } + ] + }, + "staking": { + "staking_tokens": [ + { + "denom": "nam" + } + ], + "lock_duration": { + "time": "1209600s" } - ], - "feeCurrencies": [ + }, + "codebase": { + "git_repo": "https://github.com/anoma/namada", + "genesis": { + "name": "", + "genesis_url": "" + }, + "recommended_version": "", + "compatible_versions": [""], + "cosmos_sdk_version": "", + "consensus": { + "type": "", + "version": "" + }, + "cosmwasm_version": "", + "cosmwasm_enabled": true, + "ibc_go_version": "v7.4.0", + "ics_enabled": ["ics20-1"], + "binaries": { + "linux/amd64": "", + "linux/arm64": "" + }, + "language": { + "type": "rust", + "version": "1.21.4" + }, + "sdk": { + "type": "", + "repo": "", + "version": "", + "tag": "" + }, + "ibc": { + "type": "", + "version": "", + "ics_enabled": [""] + }, + "cosmwasm": { + "version": "", + "repo": "", + "tag": "", + "enabled": true + } + }, + "images": [ { - "coinDecimals": 6, - "coinDenom": "NAM", - "coinMinimalDenom": "namnam" + "image_sync": { + "chain_name": "namada", + "base_denom": "namnam" + }, + "svg": "", + "png": "", + "theme": { + "primary_color_hex": "#ffff00" + } } ], - "stakeCurrency": { - "coinDecimals": 6, - "coinDenom": "NAM", - "coinMinimalDenom": "namnam" - } + "logo_URIs": { + "png": "", + "svg": "" + }, + "description": "", + "peers": { + "seeds": [] + }, + "keywords": ["dex"] } diff --git a/apps/namadillo/src/types.d.ts b/apps/namadillo/src/types.d.ts index 8f18d4a37..162fbbbae 100644 --- a/apps/namadillo/src/types.d.ts +++ b/apps/namadillo/src/types.d.ts @@ -167,10 +167,14 @@ export type ToastNotificationEntryFilter = ( notification: ToastNotification ) => boolean; -export type Provider = { +export type WalletProvider = { + id: ExtensionKey; name: string; iconUrl: string; - connected: boolean; + downloadUrl: { + chrome: string; + firefox: string; + }; }; export type Chain = { diff --git a/package.json b/package.json index 2f73e615c..99fb8fdca 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "docker-build-namadillo": "docker build -f docker/namadillo/Dockerfile ." }, "devDependencies": { + "@chain-registry/types": "^0.45.80", "@release-it/conventional-changelog": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", @@ -41,5 +42,8 @@ "vite-plugin-checker": "^0.6.4", "wsrun": "^5.2.4" }, - "packageManager": "yarn@4.0.2" + "packageManager": "yarn@4.0.2", + "dependencies": { + "chain-registry": "^1.63.100" + } } diff --git a/packages/integrations/src/hooks/useIntegration.ts b/packages/integrations/src/hooks/useIntegration.ts index d3c178e20..b23b56554 100644 --- a/packages/integrations/src/hooks/useIntegration.ts +++ b/packages/integrations/src/hooks/useIntegration.ts @@ -9,7 +9,7 @@ import { import { chains } from "@namada/chains"; import { useUntil } from "@namada/hooks"; import { Keplr, Metamask, Namada } from "@namada/integrations"; -import { ChainKey, ExtensionKey } from "@namada/types"; +import { ExtensionKey } from "@namada/types"; type ExtensionConnection = ( onSuccess: () => T, @@ -22,18 +22,14 @@ type IntegrationFromExtensionKey = : K extends "metamask" ? Metamask : never; -type IntegrationFromChainKey = IntegrationFromExtensionKey< - (typeof chains)[K]["extension"]["id"] ->; - type Integrations = { - [K in ChainKey]: IntegrationFromChainKey; + [K in ExtensionKey]: IntegrationFromExtensionKey; }; export const integrations: Integrations = { namada: new Namada(chains.namada), - cosmos: new Keplr(chains.cosmos), - ethereum: new Metamask(chains.ethereum), + keplr: new Keplr(chains.cosmos), + metamask: new Metamask(chains.ethereum), }; export const IntegrationsContext = createContext(integrations); @@ -44,9 +40,9 @@ export const IntegrationsContext = createContext(integrations); * @param {ChainIndex} chainKey - Index of chain integration * @returns {InstanceType} Integration API */ -export const useIntegration = ( +export const useIntegration = ( chainKey: K -): IntegrationFromChainKey => { +): IntegrationFromExtensionKey => { return useContext(IntegrationsContext)[chainKey]; }; @@ -55,18 +51,22 @@ export const useIntegration = ( * * @template TSuccess - Success return type. * @template TFail - Fail return type. - * @param {ChainKey} chainKey - Index of a chain integration + * @param {ExtensionKey} extensionKey - Index of a wallet integration * @returns {[InstanceType, boolean, ExtensionConnection]} * Tuple of integration, connection status and connection function. */ -export const useIntegrationConnection = ( - chainKey: K +export const useIntegrationConnection = < + TSuccess, + TFail, + K extends ExtensionKey, +>( + extensionKey: K ): [ - IntegrationFromChainKey, + IntegrationFromExtensionKey, boolean, ExtensionConnection, ] => { - const integration = useIntegration(chainKey); + const integration = useIntegration(extensionKey); const [isConnectingToExtension, setIsConnectingToExtension] = useState(false); const connect: ExtensionConnection = useCallback( @@ -84,7 +84,7 @@ export const useIntegrationConnection = ( } setIsConnectingToExtension(false); }, - [chainKey] + [extensionKey] ); return [integration, isConnectingToExtension, connect]; @@ -97,10 +97,9 @@ type AttachStatusMap = { [key in ExtensionKey]: AttachStatus }; * Hook used for returning attach status of extension */ export const useUntilIntegrationAttached = ( - chainId: ChainKey = "namada", extensionId: ExtensionKey = "namada" ): AttachStatus => { - const integration = useIntegration(chainId); + const integration = useIntegration(extensionId); const [attachStatusMap, setAttachStatus] = useState({ namada: "pending", keplr: "pending", @@ -109,7 +108,7 @@ export const useUntilIntegrationAttached = ( useEffect(() => { setAttachStatus((v) => ({ ...v, [extensionId]: "pending" })); - }, [chainId]); + }, [extensionId]); useUntil( { @@ -142,11 +141,11 @@ export const getIntegrations = (): Integrations => { /** * Returns integration by chainId. To be used outside react components. * - * @param {Chainkey} chainKey - Key of the chain + * @param {ExtensionKey} extensionKey - Key of the wallet * @returns {InstanceType} Integration API */ -export const getIntegration = ( - chainKey: K -): IntegrationFromChainKey => { - return integrations[chainKey]; +export const getIntegration = ( + extensionKey: K +): IntegrationFromExtensionKey => { + return integrations[extensionKey]; }; diff --git a/yarn.lock b/yarn.lock index 129084ff6..7eaf96a7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1588,6 +1588,36 @@ __metadata: languageName: node linkType: hard +"@chain-registry/client@npm:^1.48.80": + version: 1.48.80 + resolution: "@chain-registry/client@npm:1.48.80" + dependencies: + "@chain-registry/types": "npm:^0.45.80" + "@chain-registry/utils": "npm:^1.46.80" + bfs-path: "npm:^1.0.2" + cross-fetch: "npm:^3.1.5" + checksum: 63a8b398add4547145740b2ab40977a6305cd8b5c7f9c6339c46a36a28a52c476eb3a263328611db0fc986bcd24ea0e8a9fe54688acf38c8086988ce4b370ae0 + languageName: node + linkType: hard + +"@chain-registry/types@npm:^0.45.80": + version: 0.45.80 + resolution: "@chain-registry/types@npm:0.45.80" + checksum: 5ec1901865f00beda568683ca715984e0052f4c9d387a527df00538417d0bc9acaddcf8ce8d3faf8048edae66777b719b6b16d91eae030793b085c66eee71e7b + languageName: node + linkType: hard + +"@chain-registry/utils@npm:^1.46.80": + version: 1.46.80 + resolution: "@chain-registry/utils@npm:1.46.80" + dependencies: + "@chain-registry/types": "npm:^0.45.80" + bignumber.js: "npm:9.1.2" + sha.js: "npm:^2.4.11" + checksum: 096b47bc36b275ee100bb9a1dd9ce3fca6e1079230ccccbf663159376d39e743278f56bf95a2e9037a289cb6f19d1f48434f58d55c45e4ea09610d065cdf387b + languageName: node + linkType: hard + "@confio/ics23@npm:^0.6.8": version: 0.6.8 resolution: "@confio/ics23@npm:0.6.8" @@ -3625,6 +3655,7 @@ __metadata: resolution: "@namada/namadillo@workspace:apps/namadillo" dependencies: "@anomaorg/namada-indexer-client": "npm:0.0.23" + "@chain-registry/client": "npm:^1.48.80" "@cosmjs/encoding": "npm:^0.32.3" "@eslint/js": "npm:^9.9.1" "@playwright/test": "npm:^1.24.1" @@ -7025,6 +7056,13 @@ __metadata: languageName: node linkType: hard +"bfs-path@npm:^1.0.2": + version: 1.0.2 + resolution: "bfs-path@npm:1.0.2" + checksum: 776cd5cf823d0767bab64d9c029bcf3336a5ee3a3e15f8ef9186772885fa2a3dd2bf4e3a5a5e7a96d02805a85d983a51d0aff76712a5b5c0b331db37578d0b79 + languageName: node + linkType: hard + "big.js@npm:^5.2.2": version: 5.2.2 resolution: "big.js@npm:5.2.2" @@ -7032,7 +7070,7 @@ __metadata: languageName: node linkType: hard -"bignumber.js@npm:^9.1.1": +"bignumber.js@npm:9.1.2, bignumber.js@npm:^9.1.1": version: 9.1.2 resolution: "bignumber.js@npm:9.1.2" checksum: e17786545433f3110b868725c449fa9625366a6e675cd70eb39b60938d6adbd0158cb4b3ad4f306ce817165d37e63f4aa3098ba4110db1d9a3b9f66abfbaf10d @@ -7561,6 +7599,15 @@ __metadata: languageName: node linkType: hard +"chain-registry@npm:^1.63.100": + version: 1.63.100 + resolution: "chain-registry@npm:1.63.100" + dependencies: + "@chain-registry/types": "npm:^0.45.80" + checksum: 9ba5f5bdfe36891bcf94ce3e95506fb36dc953dd05e8c4664a527cad08b294e4beaabd68f409e6384cd21e44f88991475b75bc0461ba29da9fdc503d87dd05c0 + languageName: node + linkType: hard + "chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -8625,7 +8672,7 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:^3.0.4": +"cross-fetch@npm:^3.0.4, cross-fetch@npm:^3.1.5": version: 3.1.8 resolution: "cross-fetch@npm:3.1.8" dependencies: @@ -15736,9 +15783,11 @@ __metadata: version: 0.0.0-use.local resolution: "namada@workspace:." dependencies: + "@chain-registry/types": "npm:^0.45.80" "@release-it/conventional-changelog": "npm:^8.0.1" "@typescript-eslint/eslint-plugin": "npm:^8.4.0" "@typescript-eslint/parser": "npm:^8.4.0" + chain-registry: "npm:^1.63.100" eslint: "npm:^8.57.0" git-commit-msg-linter: "npm:^5.0.6" husky: "npm:^8.0.3"