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 (
+
+
+ );
+}
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 (
+
+ );
+}
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
-
-
-
-
- {showAuxView ? (
-
- ) : (
-
- )}
-
- {showAuxView === "settings" ? (
- <>
- {chainsError ? {chainsError}
: null}
-
- Settings
-
-
- 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)"
- />
-
-
-
- >
- ) : null}
- {showAuxView === "confirmRedirect" && optionToConfirm ? (
-
-
- If you change to {optionToConfirm.label} your unsaved changes will be lost and you
- will be redirected to the main screen
-
-
-
- ) : null}
-
-
-
- );
-};
-
-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
+
+
+
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?
-