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/CustomChainForm.tsx b/components/ChainConnect/CustomChainForm.tsx index bb34867..7d5afde 100644 --- a/components/ChainConnect/CustomChainForm.tsx +++ b/components/ChainConnect/CustomChainForm.tsx @@ -37,7 +37,8 @@ export default function CustomChainForm() { 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" }), - explorerLink: z.string({ required_error: "Explorer url is 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" }), }) @@ -56,7 +57,8 @@ export default function CustomChainForm() { bech32Prefix: defaultChain.addressPrefix, gasPrice: defaultChain.gasPrice, rpcNodes: defaultChain.nodeAddresses.join(", "), - explorerLink: defaultChain.explorerLink, + explorerTxLink: defaultChain.explorerLink.tx, + explorerAccountLink: defaultChain.explorerLink.account, logo: defaultChain.logo, assets: JSON.stringify(defaultChain.assets), }, @@ -80,7 +82,10 @@ export default function CustomChainForm() { assets: JSON.parse(chainFromForm.assets) as RegistryAsset[], gasPrice: chainFromForm.gasPrice, addressPrefix: chainFromForm.bech32Prefix, - explorerLink: chainFromForm.explorerLink, + explorerLink: { + tx: chainFromForm.explorerTxLink, + account: chainFromForm.explorerAccountLink, + }, }, }); } @@ -190,10 +195,10 @@ export default function CustomChainForm() { )} /> ( - Explorer Link + Explorer Tx Link @@ -202,6 +207,19 @@ export default function CustomChainForm() { )} /> + ( + + Explorer Account Link + + + + with {"'${accountAddress}'"} included + + + )} + /> ) : ( <> - +
-
diff --git a/components/Header.tsx b/components/Header.tsx index 0044d3a..47e08f7 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,9 +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/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?

-