diff --git a/.env.sample b/.env.sample index aceb264..11f106e 100644 --- a/.env.sample +++ b/.env.sample @@ -1,15 +1,4 @@ FAUNADB_SECRET= FAUNADB_URL=https://graphql.eu.fauna.com/graphql -NEXT_PUBLIC_NODE_ADDRESS=https://cosmoshub.validator.network:443 -NEXT_PUBLIC_DENOM=uatom -NEXT_PUBLIC_DISPLAY_DENOM=ATOM -NEXT_PUBLIC_DISPLAY_DENOM_EXPONENT=6 -NEXT_PUBLIC_ASSETS=[{"description":"The native staking and governance token of the Cosmos Hub.","denom_units":[{"denom":"uatom","exponent":0},{"denom":"atom","exponent":6}],"base":"uatom","name":"Cosmos Hub Atom","display":"atom","symbol":"ATOM","logo_URIs":{"png":"https://raw.githubusercontent.com/cosmos/chain-registry/master/cosmoshub/images/atom.png","svg":"https://raw.githubusercontent.com/cosmos/chain-registry/master/cosmoshub/images/atom.svg"},"coingecko_id":"cosmos"}] -NEXT_PUBLIC_GAS_PRICE=0.03uatom -NEXT_PUBLIC_CHAIN_ID=cosmoshub-4 -NEXT_PUBLIC_ADDRESS_PREFIX=cosmos -NEXT_PUBLIC_REGISTRY_NAME=cosmoshub -NEXT_PUBLIC_EXPLORER_LINK_TX="https://www.mintscan.io/cosmos/txs/\${txHash}" -NEXT_PUBLIC_EXPLORER_LINK_ACCOUNT="https://www.mintscan.io/cosmos/account/\${address}" -NEXT_PUBLIC_CHAIN_DISPLAY_NAME="Cosmos Hub" NEXT_PUBLIC_MULTICHAIN=true +NEXT_PUBLIC_REGISTRY_NAME=cosmoshub diff --git a/.env.test.sample b/.env.test.sample index 15ec22d..3e303cb 100644 --- a/.env.test.sample +++ b/.env.test.sample @@ -1,15 +1,4 @@ FAUNADB_SECRET= FAUNADB_URL=https://graphql.eu.fauna.com/graphql -NEXT_PUBLIC_NODE_ADDRESS=https://rpc.uni.junonetwork.io:443 -NEXT_PUBLIC_DENOM=ujunox -NEXT_PUBLIC_DISPLAY_DENOM=JUNOX -NEXT_PUBLIC_DISPLAY_DENOM_EXPONENT=6 -NEXT_PUBLIC_ASSETS=[{"description":"The native token of JUNO Chain","denom_units":[{"denom":"ujunox","exponent":0},{"denom":"junox","exponent":6}],"base":"ujunox","name":"Juno Testnet","display":"junox","symbol":"JUNOX","logo_URIs":{"png":"https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/junotestnet/images/juno.png","svg":"https://raw.githubusercontent.com/cosmos/chain-registry/master/testnets/junotestnet/images/juno.svg"},"coingecko_id":"juno-network"}] -NEXT_PUBLIC_GAS_PRICE=0.04ujunox -NEXT_PUBLIC_CHAIN_ID=uni-6 -NEXT_PUBLIC_ADDRESS_PREFIX=juno -NEXT_PUBLIC_REGISTRY_NAME=junotestnet -NEXT_PUBLIC_EXPLORER_LINK_TX="https://testnet.mintscan.io/juno-testnet/txs/\${txHash}" -NEXT_PUBLIC_EXPLORER_LINK_ACCOUNT="https://testnet.mintscan.io/juno-testnet/account/\${address}" -NEXT_PUBLIC_CHAIN_DISPLAY_NAME="Juno Testnet" NEXT_PUBLIC_MULTICHAIN=true +NEXT_PUBLIC_REGISTRY_NAME=junotestnet diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e0ce990 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +# Shadcn ui components +/components/ui/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e0ce990 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Shadcn ui components +/components/ui/ diff --git a/components/BadgeWithCopy.tsx b/components/BadgeWithCopy.tsx new file mode 100644 index 0000000..bf3f6f3 --- /dev/null +++ b/components/BadgeWithCopy.tsx @@ -0,0 +1,26 @@ +import copy from "copy-to-clipboard"; +import { Copy } from "lucide-react"; +import { Badge } from "./ui/badge"; +import { useToast } from "./ui/use-toast"; + +interface BadgeWithCopyProps { + readonly name: string; + readonly toCopy: string; +} + +export default function BadgeWithCopy({ name, toCopy }: BadgeWithCopyProps) { + const { toast } = useToast(); + + return ( + { + copy(toCopy); + toast({ description: `Copied ${name} to clipboard` }); + }} + className="max-w-md self-start truncate hover:cursor-pointer" + > + + {toCopy} + + ); +} diff --git a/components/ChainConnect/ChainDigest.tsx b/components/ChainConnect/ChainDigest.tsx new file mode 100644 index 0000000..39e185b --- /dev/null +++ b/components/ChainConnect/ChainDigest.tsx @@ -0,0 +1,104 @@ +import { useChains } from "@/context/ChainsContext"; +import { deleteLocalChainFromStorage } from "@/context/ChainsContext/storage"; +import { ChainInfo } from "@/context/ChainsContext/types"; +import { CheckCircle, ChevronsUpDown, ExternalLink } from "lucide-react"; +import Link from "next/link"; +import ButtonWithConfirm from "../inputs/ButtonWithConfirm"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; + +interface ChainItemProps { + readonly chain: ChainInfo; + readonly simplify?: boolean; +} + +export default function ChainDigest({ chain, simplify }: ChainItemProps) { + const { chain: connectedChain, chains } = useChains(); + + return ( +
+
+ + {!simplify && connectedChain.registryName === chain.registryName ? ( + + ) : null} + + {chain.registryName.slice(0, 1).toUpperCase()} + +

{chain.chainDisplayName}

+ {chain.chainId} +
+

Fee token: {chain.displayDenom}

+ +
+ {!simplify && chain.nodeAddresses.length > 1 ? ( + + + + ) : ( +

RPC endpoint:

+ )} +
+
+ {chain.nodeAddress || chain.nodeAddresses[0]} +
+ + {chain.nodeAddresses + .filter( + (address, _, nodeAddresses) => + (chain.nodeAddress && address !== chain.nodeAddress) || + (!chain.nodeAddress && address !== nodeAddresses[0]), + ) + .map((address) => ( +
+ {address} +
+ ))} +
+
+ {!simplify && chains.localnets.has(chain.registryName) ? ( +
+ { + deleteLocalChainFromStorage(chain.registryName, chains); + }} + text="Delete custom chain" + confirmText="Confirm deletion?" + disabled={connectedChain.registryName === chain.registryName} + /> +
+ ) : !simplify ? ( +
+ + + Check it out on the registry + +
+ ) : null} +
+ ); +} diff --git a/components/ChainConnect/ChainItem.tsx b/components/ChainConnect/ChainItem.tsx new file mode 100644 index 0000000..a8bf14e --- /dev/null +++ b/components/ChainConnect/ChainItem.tsx @@ -0,0 +1,72 @@ +import { useChains } from "@/context/ChainsContext"; +import { setNewConnection } from "@/context/ChainsContext/helpers"; +import { ChainInfo } from "@/context/ChainsContext/types"; +import { cn } from "@/lib/utils"; +import { CheckCircle } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { CommandItem } from "../ui/command"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card"; +import ChainDigest from "./ChainDigest"; + +interface ChainItemProps { + readonly chain: ChainInfo; + readonly hoverCardElementBoundary: HTMLDivElement | null; +} + +export default function ChainItem({ chain, hoverCardElementBoundary }: ChainItemProps) { + const { chain: connectedChain, chainsDispatch } = useChains(); + + return ( + + + {} + : () => { + setNewConnection(chainsDispatch, { action: "confirm", chain }); + } + } + className={cn( + "group relative inline-flex cursor-pointer items-center justify-center gap-2 rounded-full border-2 border-white p-2.5 text-sm font-medium text-white focus:outline-none focus:ring-4 focus:ring-red-100 aria-selected:text-gray-900", + connectedChain.registryName === chain.registryName + ? "cursor-not-allowed border-green-600 bg-green-300 text-green-900 aria-selected:bg-green-300" + : "transparent cursor-pointer border-white", + )} + > + {connectedChain.registryName === chain.registryName ? ( + + ) : null} + + + {chain.registryName.slice(0, 1).toUpperCase()} + + {chain.registryName} + + + + + + + ); +} diff --git a/components/ChainConnect/ChainsGroup.tsx b/components/ChainConnect/ChainsGroup.tsx new file mode 100644 index 0000000..96007db --- /dev/null +++ b/components/ChainConnect/ChainsGroup.tsx @@ -0,0 +1,35 @@ +import { ChainInfo } from "@/context/ChainsContext/types"; +import { CommandGroup } from "../ui/command"; +import ChainItem from "./ChainItem"; +import { useRef } from "react"; + +interface ChainsGroupProps { + readonly chains: readonly ChainInfo[]; + readonly heading: string; + readonly emptyMsg: string; +} + +export default function ChainsGroup({ chains, heading, emptyMsg }: ChainsGroupProps) { + const containerRef = useRef(null); + + return ( + + {chains.length ? ( +
+ {chains.map((chain) => ( + + ))} +
+ ) : ( +

{emptyMsg}

+ )} +
+ ); +} diff --git a/components/ChainConnect/ChooseChain.tsx b/components/ChainConnect/ChooseChain.tsx new file mode 100644 index 0000000..4074bba --- /dev/null +++ b/components/ChainConnect/ChooseChain.tsx @@ -0,0 +1,58 @@ +import { useChains } from "@/context/ChainsContext"; +import { getRecentChainsFromStorage } from "@/context/ChainsContext/storage"; +import { ChainInfo } from "@/context/ChainsContext/types"; +import { useLayoutEffect, useState } from "react"; +import { Command, CommandEmpty, CommandInput, CommandList, CommandSeparator } from "../ui/command"; +import ChainsGroup from "./ChainsGroup"; + +export default function ChooseChain() { + const { chains } = useChains(); + const [recentChains, setRecentChains] = useState([]); + + useLayoutEffect(() => { + const newRecentChains = getRecentChainsFromStorage(chains); + setRecentChains(newRecentChains); + }, [chains]); + + return ( + + +
+ + No results found. + + + + + + + + +
+
+ ); +} diff --git a/components/ChainConnect/ConfirmConnection.tsx b/components/ChainConnect/ConfirmConnection.tsx new file mode 100644 index 0000000..07732ed --- /dev/null +++ b/components/ChainConnect/ConfirmConnection.tsx @@ -0,0 +1,61 @@ +import { useChains } from "@/context/ChainsContext"; +import { setChain, setNewConnection } from "@/context/ChainsContext/helpers"; +import { useRouter } from "next/router"; +import { Button } from "../ui/button"; +import ChainDigest from "./ChainDigest"; + +interface ConfirmConnectionProps { + readonly closeDialog: () => void; +} + +export default function ConfirmConnection({ closeDialog }: ConfirmConnectionProps) { + const router = useRouter(); + const { chain, newConnection, chainsDispatch } = useChains(); + + if (newConnection.action !== "confirm") { + return null; + } + + return ( + <> +

+ Disconnect from "{chain.registryName}" and connect to "{newConnection.chain.registryName}"? +

+

+ You will be redirected to the homepage and any filled form will be lost +

+
+
+ +
+
+ +
+
+
+ + +
+ + ); +} diff --git a/components/ChainConnect/CustomChainForm.tsx b/components/ChainConnect/CustomChainForm.tsx new file mode 100644 index 0000000..7d5afde --- /dev/null +++ b/components/ChainConnect/CustomChainForm.tsx @@ -0,0 +1,274 @@ +import { useChains } from "@/context/ChainsContext"; +import { setNewConnection } from "@/context/ChainsContext/helpers"; +import { RegistryAsset } from "@/types/chainRegistry"; +import { zodResolver } from "@hookform/resolvers/zod"; +import dynamic from "next/dynamic"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "../ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; +import { Input } from "../ui/input"; + +const JsonEditor = dynamic(() => import("../inputs/JsonEditor"), { ssr: false }); + +export default function CustomChainForm() { + const { chain, chains, newConnection, chainsDispatch } = useChains(); + + const formSchema = z + .object({ + localRegistryName: z + .string({ required_error: "Local registry name is required" }) + .refine((val) => !chains.mainnets.has(val) && !chains.testnets.has(val), { + message: "Name already exists in remote registry", + }), + chainName: z.string({ required_error: "Chain name is required" }), + chainId: z.string({ required_error: "Chain ID is required" }), + baseDenom: z.string({ required_error: "Base denom is required" }), + displayDenom: z.string({ required_error: "Display denom is required" }), + denomExponent: z.string({ required_error: "Denom exponent is required" }), + bech32Prefix: z.string({ required_error: "Address prefix is required" }), + gasPrice: z.string({ required_error: "Gas price is required" }), + rpcNodes: z.string({ required_error: "Comma separated rpc nodes are required" }), + explorerTxLink: z.string({ required_error: "Explorer tx url is required" }), + explorerAccountLink: z.string({ required_error: "Explorer account url is required" }), + logo: z.string({ required_error: "Logo url is required" }), + assets: z.string({ required_error: "Assets json is required" }), + }) + .required(); + + const defaultChain = newConnection.chain ?? chain; + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + localRegistryName: defaultChain.registryName, + chainName: defaultChain.chainDisplayName, + chainId: defaultChain.chainId, + baseDenom: defaultChain.denom, + displayDenom: defaultChain.displayDenom, + denomExponent: String(defaultChain.displayDenomExponent), + bech32Prefix: defaultChain.addressPrefix, + gasPrice: defaultChain.gasPrice, + rpcNodes: defaultChain.nodeAddresses.join(", "), + explorerTxLink: defaultChain.explorerLink.tx, + explorerAccountLink: defaultChain.explorerLink.account, + logo: defaultChain.logo, + assets: JSON.stringify(defaultChain.assets), + }, + }); + + function onSubmit(chainFromForm: z.infer) { + const rpcNodes = chainFromForm.rpcNodes.split(", "); + + setNewConnection(chainsDispatch, { + action: "confirm", + chain: { + registryName: chainFromForm.localRegistryName, + logo: chainFromForm.logo, + chainId: chainFromForm.chainId, + chainDisplayName: chainFromForm.chainName, + nodeAddress: "", + nodeAddresses: rpcNodes, + denom: chainFromForm.baseDenom, + displayDenom: chainFromForm.displayDenom, + displayDenomExponent: Number(chainFromForm.denomExponent), + assets: JSON.parse(chainFromForm.assets) as RegistryAsset[], + gasPrice: chainFromForm.gasPrice, + addressPrefix: chainFromForm.bech32Prefix, + explorerLink: { + tx: chainFromForm.explorerTxLink, + account: chainFromForm.explorerAccountLink, + }, + }, + }); + } + + return ( +
+ +
+ ( + + Local Registry Name + + + + + A unique key to store this chain on your local registry + + + + )} + /> + ( + + Chain Name + + + + + + )} + /> + ( + + Chain ID + + + + + + )} + /> + ( + + Base Denom + + + + + + )} + /> + ( + + Display Denom + + + + + + )} + /> + ( + + Denom Exponent + + + + + + )} + /> + ( + + Address Prefix + + + + Needs to be bech32 + + + )} + /> + ( + + Gas Price + + + + + + )} + /> + ( + + Explorer Tx Link + + + + with {"'${txHash}'"} included + + + )} + /> + ( + + Explorer Account Link + + + + with {"'${accountAddress}'"} included + + + )} + /> +
+ ( + + RPC nodes + + + + Can be one or more, separated by commas + + + )} + /> + ( + + Logo URI + + + + + + )} + /> + ( + + Assets + +
+ { + field.onChange("text" in newMsgContent ? newMsgContent.text ?? "{}" : "{}"); + }} + /> +
+
+ +
+ )} + /> + + + + ); +} diff --git a/components/ChainConnect/DialogButton.tsx b/components/ChainConnect/DialogButton.tsx new file mode 100644 index 0000000..7b2fb3c --- /dev/null +++ b/components/ChainConnect/DialogButton.tsx @@ -0,0 +1,42 @@ +import { useChains } from "@/context/ChainsContext"; +import { isChainInfoFilled } from "@/context/ChainsContext/helpers"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { DialogTrigger } from "../ui/dialog"; +import { Skeleton } from "../ui/skeleton"; + +export function ChainHeader() { + const { chain } = useChains(); + + return isChainInfoFilled(chain) ? ( + <> + + + {chain.registryName.slice(0, 1).toUpperCase()} + +

{chain.chainDisplayName} Multisig

+ + ) : ( + <> + +
+ +
+ + ); +} + +export default function DialogButton() { + const showChainSelect = process.env.NEXT_PUBLIC_MULTICHAIN?.toLowerCase() === "true"; + + return showChainSelect ? ( + +
+ +
+
+ ) : ( +
+ +
+ ); +} diff --git a/components/ChainConnect/TabButton.tsx b/components/ChainConnect/TabButton.tsx new file mode 100644 index 0000000..0feff93 --- /dev/null +++ b/components/ChainConnect/TabButton.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; +import { ComponentProps } from "react"; +import { TabsTrigger } from "../ui/tabs"; + +export default function TabButton({ + value, + children, + className, + ...restProps +}: ComponentProps) { + return ( + + {children} + + ); +} diff --git a/components/ChainConnect/index.tsx b/components/ChainConnect/index.tsx new file mode 100644 index 0000000..9f8b6ae --- /dev/null +++ b/components/ChainConnect/index.tsx @@ -0,0 +1,52 @@ +import { useChains } from "@/context/ChainsContext"; +import { setNewConnection } from "@/context/ChainsContext/helpers"; +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader } from "../ui/dialog"; +import { Tabs, TabsContent, TabsList } from "../ui/tabs"; +import ChooseChain from "./ChooseChain"; +import ConfirmConnection from "./ConfirmConnection"; +import CustomChainForm from "./CustomChainForm"; +import DialogButton from "./DialogButton"; +import TabButton from "./TabButton"; + +const tabs = { choose: "choose", custom: "custom" }; + +export default function ChainConnect() { + const { newConnection, chainsDispatch } = useChains(); + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + { + setDialogOpen(open); + setNewConnection(chainsDispatch, { action: "edit" }); + }} + > + + + {newConnection.action === "confirm" ? ( + setDialogOpen(false)} /> + ) : ( + + + + Choose chain + Custom chain + + + + + + + + + + )} + + + ); +} diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..47e08f7 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,29 @@ +import { useChains } from "@/context/ChainsContext"; +import { UserCircle2 } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import ChainConnect from "./ChainConnect"; + +export default function Header() { + const { pathname } = useRouter(); + const { chain } = useChains(); + + return ( +
+ + + + +
+ ); +} diff --git a/components/chainSelect/ChainSelect.tsx b/components/chainSelect/ChainSelect.tsx deleted file mode 100644 index 5abe05a..0000000 --- a/components/chainSelect/ChainSelect.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { StargateClient } from "@cosmjs/stargate"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; -import { useChains } from "../../context/ChainsContext"; -import { - setChain, - setChainFromRegistry, - setChainsError, -} from "../../context/ChainsContext/helpers"; -import { RegistryAsset } from "../../types/chainRegistry"; -import GearIcon from "../icons/Gear"; -import Button from "../inputs/Button"; -import Input from "../inputs/Input"; -import Select from "../inputs/Select"; -import StackableContainer from "../layout/StackableContainer"; - -interface ChainOption { - readonly label: string; - readonly value: string; -} - -const ChainSelect = () => { - const router = useRouter(); - const { chain, chains, chainsError, chainsDispatch } = useChains(); - - const [optionToConfirm, setOptionToConfirm] = useState(null); - const [showAuxView, setShowAuxView] = useState(null); - const [chainInForm, setChainInForm] = useState(chain); - const [stringAssets, setStringAssets] = useState(JSON.stringify(chain.assets)); - - const chainArray = [...chains.mainnets, ...chains.testnets]; - const chainOptions: readonly ChainOption[] = chainArray.map(({ name }) => ({ - label: name, - value: name, - })); - const selectValue = chainOptions.find((option) => option.value === chain.registryName) ?? { - label: "unknown chain", - value: chain.registryName, - }; - - useEffect(() => { - setChainInForm(chain); - setStringAssets(JSON.stringify(chain.assets)); - }, [chain]); - - useEffect(() => { - try { - const assets: readonly RegistryAsset[] = JSON.parse(stringAssets); - setChainInForm((oldChain) => ({ ...oldChain, assets })); - } catch { - setChainsError(chainsDispatch, "Assets needs to be valid JSON"); - } - }, [chainsDispatch, stringAssets]); - - const selectChainOption = (chainOption: ChainOption) => { - if (router.pathname !== "/" && chainOption.value !== selectValue.value) { - setOptionToConfirm(chainOption); - setShowAuxView("confirmRedirect"); - return; - } - - setChainsError(chainsDispatch, null); - setChainFromRegistry(chainsDispatch, chainOption.value); - setOptionToConfirm(null); - }; - - const redirectAndChangeChain = () => { - setShowAuxView(null); - - if (optionToConfirm) { - setChainFromRegistry(chainsDispatch, optionToConfirm.value); - setOptionToConfirm(null); - } - - router.push("/"); - }; - - const setChainFromForm = async () => { - setChainsError(chainsDispatch, null); - - try { - // test client connection - const client = await StargateClient.connect(chainInForm.nodeAddress); - await client.getHeight(); - - setShowAuxView(null); - setChain(chainsDispatch, chainInForm); - } catch (error) { - if (error instanceof Error) { - setChainsError(chainsDispatch, error.message); - } else { - setChainsError(chainsDispatch, "Error when setting new chain"); - } - setShowAuxView("settings"); - } - }; - - return ( -
- -

Chain select

-
-
- - setChainInForm((oldChain) => ({ ...oldChain, chainDisplayName: target.value })) - } - label="Chain Name" - /> - - - setChainInForm((oldChain) => ({ ...oldChain, chainId: target.value })) - } - label="Chain ID" - /> -
-
- - setChainInForm((oldChain) => ({ ...oldChain, addressPrefix: target.value })) - } - label="Bech32 Prefix (address prefix)" - /> - - setChainInForm((oldChain) => ({ ...oldChain, nodeAddress: target.value })) - } - label="RPC Node URL (must be https)" - /> -
-
- - setChainInForm((oldChain) => ({ ...oldChain, displayDenom: target.value })) - } - label="Display Denom" - /> - - setChainInForm((oldChain) => ({ ...oldChain, denom: target.value })) - } - label="Base Denom" - /> -
-
- - setChainInForm((oldChain) => ({ - ...oldChain, - displayDenomExponent: Number(target.value), - })) - } - label="Denom Exponent" - /> - setStringAssets(target.value)} - label="Assets" - /> -
-
- - setChainInForm((oldChain) => ({ ...oldChain, gasPrice: target.value })) - } - label="Gas Price" - /> - - setChainInForm((oldChain) => ({ ...oldChain, explorerLink: target.value })) - } - label="Explorer Link (with '${txHash}' included)" - /> -
-
- ); -}; - -export default ChainSelect; diff --git a/components/dataViews/AccountView/BalancePill.tsx b/components/dataViews/AccountView/BalancePill.tsx new file mode 100644 index 0000000..c0df9a7 --- /dev/null +++ b/components/dataViews/AccountView/BalancePill.tsx @@ -0,0 +1,29 @@ +import { printableCoin } from "@/lib/displayHelpers"; +import { Coin } from "@cosmjs/amino"; +import { useChains } from "../../../context/ChainsContext"; +import { Avatar, AvatarFallback, AvatarImage } from "../../ui/avatar"; +import { Badge } from "../../ui/badge"; + +interface BalancePillProps { + readonly coin: Coin; +} + +export default function BalancePill({ coin }: BalancePillProps) { + const { chain } = useChains(); + + const foundAsset = chain.assets.find((asset) => asset.base === coin.denom); + const logo = foundAsset?.logo_URIs?.svg || foundAsset?.logo_URIs?.png || ""; + const macroCoin = printableCoin(coin, chain); + + return ( + + + + + {coin.denom.slice(1, 2).toUpperCase()} + + + {macroCoin} + + ); +} diff --git a/components/dataViews/AccountView/BalancesList.tsx b/components/dataViews/AccountView/BalancesList.tsx new file mode 100644 index 0000000..5f8ab73 --- /dev/null +++ b/components/dataViews/AccountView/BalancesList.tsx @@ -0,0 +1,46 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Coin } from "@cosmjs/amino"; +import { StargateClient } from "@cosmjs/stargate"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { useChains } from "../../../context/ChainsContext"; +import BalancePill from "./BalancePill"; + +interface BalancesListProps { + readonly walletAddress: string; + readonly setError: Dispatch>; +} + +export default function BalancesList({ walletAddress, setError }: BalancesListProps) { + const { chain } = useChains(); + const [balances, setBalances] = useState([]); + + useEffect(() => { + (async function () { + if (!walletAddress) { + return; + } + + try { + const client = await StargateClient.connect(chain.nodeAddress); + const newBalances = await client.getAllBalances(walletAddress); + setBalances(newBalances); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to get balances"); + console.error("Get balances error:", e); + } + })(); + }, [chain.nodeAddress, setError, walletAddress]); + + return balances.length ? ( + + + Balances + + + {balances.map((coin) => ( + + ))} + + + ) : null; +} diff --git a/components/dataViews/AccountView/ButtonConnectWallet.tsx b/components/dataViews/AccountView/ButtonConnectWallet.tsx new file mode 100644 index 0000000..02e23fa --- /dev/null +++ b/components/dataViews/AccountView/ButtonConnectWallet.tsx @@ -0,0 +1,122 @@ +import { LoadingStates, WalletInfo, WalletType } from "@/types/signing"; +import { makeCosmoshubPath } from "@cosmjs/amino"; +import { toBase64 } from "@cosmjs/encoding"; +import { LedgerSigner } from "@cosmjs/ledger-amino"; +import TransportWebUSB from "@ledgerhq/hw-transport-webusb"; +import { Loader2 } from "lucide-react"; +import Image from "next/image"; +import { Dispatch, SetStateAction, useCallback, useLayoutEffect, useState } from "react"; +import { useChains } from "../../../context/ChainsContext"; +import { getConnectError } from "../../../lib/errorHelpers"; +import { Button } from "../../ui/button"; + +interface ButtonConnectWalletProps { + readonly walletType: WalletType; + readonly walletInfoState: [ + WalletInfo | null | undefined, + Dispatch>, + ]; + readonly setError: Dispatch>; +} + +export default function ButtonConnectWallet({ + walletType, + walletInfoState: [walletInfo, setWalletInfo], + setError, +}: ButtonConnectWalletProps) { + const { chain } = useChains(); + const [loading, setLoading] = useState({}); + + const connectKeplr = useCallback(async () => { + try { + setError(""); + setLoading((oldLoading) => ({ ...oldLoading, keplr: true })); + + await window.keplr.enable(chain.chainId); + window.keplr.defaultOptions = { + sign: { preferNoSetFee: true, preferNoSetMemo: true, disableBalanceCheck: true }, + }; + + const { bech32Address: address, pubKey: pubKeyArray } = await window.keplr.getKey( + chain.chainId, + ); + const pubKey = toBase64(pubKeyArray); + + setWalletInfo({ type: "Keplr", address, pubKey }); + } catch (e) { + console.error(e); + setError(getConnectError(e)); + } finally { + setLoading((newLoading) => ({ ...newLoading, keplr: false })); + } + }, [chain.chainId, setError, setWalletInfo]); + + useLayoutEffect(() => { + if (!walletInfo?.address) { + return; + } + + const accountChangeKey = "keplr_keystorechange"; + + if (walletInfo.type === "Keplr") { + window.addEventListener(accountChangeKey, connectKeplr); + } else { + window.removeEventListener(accountChangeKey, connectKeplr); + } + }, [connectKeplr, walletInfo]); + + const connectLedger = async () => { + try { + setError(""); + setLoading((newLoading) => ({ ...newLoading, ledger: true })); + + const ledgerTransport = await TransportWebUSB.create(120000, 120000); + const offlineSigner = new LedgerSigner(ledgerTransport, { + hdPaths: [makeCosmoshubPath(0)], + prefix: chain.addressPrefix, + }); + + const [{ address, pubkey: pubKeyArray }] = await offlineSigner.getAccounts(); + const pubKey = toBase64(pubKeyArray); + + setWalletInfo({ type: "Ledger", address, pubKey }); + } catch (e) { + console.error(e); + setError(getConnectError(e)); + } finally { + setLoading((newLoading) => ({ ...newLoading, ledger: false })); + } + }; + + const onClick = (() => { + if (walletType === "Keplr") { + return connectKeplr; + } + + if (walletType === "Ledger") { + return connectLedger; + } + + return () => {}; + })(); + + const isLoading = + (walletType === "Keplr" && loading.keplr) || (walletType === "Ledger" && loading.ledger); + + return ( + + ); +} diff --git a/components/dataViews/AccountView/index.tsx b/components/dataViews/AccountView/index.tsx new file mode 100644 index 0000000..547a965 --- /dev/null +++ b/components/dataViews/AccountView/index.tsx @@ -0,0 +1,101 @@ +import BadgeWithCopy from "@/components/BadgeWithCopy"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { explorerLinkAccount } from "@/lib/displayHelpers"; +import { WalletInfo } from "@/types/signing"; +import { AlertCircle, Unplug } from "lucide-react"; +import Image from "next/image"; +import { useState } from "react"; +import { useChains } from "../../../context/ChainsContext"; +import { Button } from "../../ui/button"; +import BalancesList from "./BalancesList"; +import ButtonConnectWallet from "./ButtonConnectWallet"; + +export default function AccountView() { + const { chain } = useChains(); + + const walletInfoState = useState(); + const [walletInfo, setWalletInfo] = walletInfoState; + const [error, setError] = useState(""); + + const explorerLink = + explorerLinkAccount(chain.explorerLink.account, walletInfo?.address || "") || ""; + + return ( +
+ + + + {walletInfo?.type ? ( + + ) : null} + {walletInfo?.type ? `${walletInfo.type} wallet connected` : "Connect wallet"} + + + {walletInfo ? ( + +

Address

+
+ + {explorerLink ? ( + + ) : null} +
+
+

Public key

+ +
+
+ ) : null} + + {error ? ( + + + Error + {error} + + ) : null} + {walletInfo?.type ? ( + + ) : ( +
+ + +
+ )} +
+
+ {walletInfo?.address ? ( + + ) : null} +
+ ); +} diff --git a/components/dataViews/CompletedTransaction.tsx b/components/dataViews/CompletedTransaction.tsx index ec49e71..0665607 100644 --- a/components/dataViews/CompletedTransaction.tsx +++ b/components/dataViews/CompletedTransaction.tsx @@ -10,8 +10,8 @@ interface CompletedTransactionProps { const CompletedTransaction = ({ transactionHash }: CompletedTransactionProps) => { const { chain } = useChains(); - const baseURL = chain.explorerLink ? chain.explorerLink : ""; - const explorerLink = explorerLinkTx(baseURL, transactionHash); + const explorerLink = explorerLinkTx(chain.explorerLink.tx, transactionHash); + return ( diff --git a/components/forms/FindMultisigForm.tsx b/components/forms/FindMultisigForm.tsx index bba3adf..98841aa 100644 --- a/components/forms/FindMultisigForm.tsx +++ b/components/forms/FindMultisigForm.tsx @@ -69,7 +69,10 @@ const FindMultisigForm = (props: Props) => {

Don't have a multisig?

-