Merge pull request #208 from cosmos/feat/new-multisig-view
Add new multisig view
This commit is contained in:
@@ -9,7 +9,7 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useChains } from "../../../context/ChainsContext";
|
||||
import { Button } from "../../ui/button";
|
||||
import BalancesTable from "./BalancesTable";
|
||||
import BalancesTable from "../BalancesTable";
|
||||
import ButtonConnectWallet from "./ButtonConnectWallet";
|
||||
|
||||
export default function AccountView() {
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { toastError } from "@/lib/utils";
|
||||
import { Coin } from "@cosmjs/amino";
|
||||
import { StargateClient } from "@cosmjs/stargate";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useChains } from "../../../context/ChainsContext";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../ui/avatar";
|
||||
import { useChains } from "../../context/ChainsContext";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
|
||||
interface BalancesTableProps {
|
||||
readonly walletAddress: string;
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { isChainInfoFilled } from "@/context/ChainsContext/helpers";
|
||||
import { explorerLinkAccount } from "@/lib/displayHelpers";
|
||||
import { getMultisigAccount } from "@/lib/multisigHelpers";
|
||||
import { toastError } from "@/lib/utils";
|
||||
import { SinglePubkey, isSecp256k1Pubkey, pubkeyToAddress } from "@cosmjs/amino";
|
||||
import { StargateClient } from "@cosmjs/stargate";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { AlertCircle, ArrowUpRightSquare, Copy, Info, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useChains } from "../../../context/ChainsContext";
|
||||
import { Button } from "../../ui/button";
|
||||
import BalancesTable from "../BalancesTable";
|
||||
|
||||
type MultisigInfo =
|
||||
| {
|
||||
readonly status: "success";
|
||||
readonly address: string;
|
||||
readonly members: readonly SinglePubkey[];
|
||||
readonly threshold: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "error";
|
||||
readonly error: "account-not-found" | "pubkeys-unavailable";
|
||||
}
|
||||
| {
|
||||
readonly status: "loading";
|
||||
};
|
||||
|
||||
export default function MultisigView() {
|
||||
const router = useRouter();
|
||||
const { chain } = useChains();
|
||||
|
||||
const [multisigInfo, setMultisigInfo] = useState<MultisigInfo>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
(async function fetchMultisigInfo() {
|
||||
try {
|
||||
const multisigAddress =
|
||||
typeof router.query.address === "string" ? router.query.address : null;
|
||||
|
||||
if (!multisigAddress || !chain.nodeAddress || !isChainInfoFilled(chain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await StargateClient.connect(chain.nodeAddress);
|
||||
const [pubkey, account] = await getMultisigAccount(
|
||||
multisigAddress,
|
||||
chain.addressPrefix,
|
||||
client,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
setMultisigInfo({ status: "error", error: "account-not-found" });
|
||||
} else {
|
||||
setMultisigInfo({
|
||||
status: "success",
|
||||
address: account.address,
|
||||
members: pubkey.value.pubkeys,
|
||||
threshold: pubkey.value.threshold,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to find multisig:", e);
|
||||
setMultisigInfo({ status: "error", error: "pubkeys-unavailable" });
|
||||
toastError({
|
||||
description: "Failed to find multisig",
|
||||
fullError: e instanceof Error ? e : undefined,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [chain, router.query.address]);
|
||||
|
||||
const explorerLink =
|
||||
multisigInfo.status === "success"
|
||||
? explorerLinkAccount(chain.explorerLinks.account, multisigInfo.address)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Multisig info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{multisigInfo.status === "success" ? (
|
||||
<>
|
||||
<div
|
||||
onClick={async () => {
|
||||
copy(multisigInfo.address);
|
||||
toast(`Copied address to clipboard`, { description: multisigInfo.address });
|
||||
}}
|
||||
className=" flex items-center space-x-4 rounded-md border p-4 transition-colors hover:cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<Copy className="w-5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Multisig address</p>
|
||||
<p className="text-sm text-muted-foreground">{multisigInfo.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
{explorerLink ? (
|
||||
<Button asChild variant="secondary">
|
||||
<a href={explorerLink} target="_blank">
|
||||
View in explorer <ArrowUpRightSquare className="ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<h4 className="font-bold">Members</h4>
|
||||
<div className="mx-4 flex flex-col gap-2">
|
||||
{multisigInfo.members.map((member) => {
|
||||
const memberAddress = pubkeyToAddress(member, chain.addressPrefix);
|
||||
// simplePubkey is base64 encoded compressed secp256k1 in almost every case. The fallback is added to be safe though.
|
||||
const simplePubkey = isSecp256k1Pubkey(member)
|
||||
? member.value
|
||||
: `${member.type} pubkey`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={memberAddress}
|
||||
onClick={async () => {
|
||||
copy(memberAddress);
|
||||
toast(`Copied address to clipboard`, { description: memberAddress });
|
||||
}}
|
||||
className="flex items-center space-x-2 rounded-md border p-2 transition-colors hover:cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<Copy className="w-5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{memberAddress}</p>
|
||||
<p className="text-sm text-muted-foreground">{simplePubkey}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-secondary">
|
||||
<Info className="text-secondary" />
|
||||
<p>{multisigInfo.threshold} signatures needed to send a transaction.</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{multisigInfo.status === "error" ? (
|
||||
<>
|
||||
{multisigInfo.error === "account-not-found" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
An account needs to be present on chain before creating a transaction. Send some
|
||||
tokens to the address first.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{multisigInfo.error === "pubkeys-unavailable" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
This multisig address's pubkeys are not available, and so it cannot be used
|
||||
with this tool.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
You can recreate it with this tool here, or sign and broadcast a transaction
|
||||
with the tool you used to create it. Either option will make the pubkeys
|
||||
accessible and will allow this tool to use this multisig fully.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{multisigInfo.status === "loading" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin" />
|
||||
<p>Loading multisig info</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{multisigInfo.status === "success" ? (
|
||||
<>
|
||||
<Button asChild variant="secondary" className="my-4">
|
||||
<a href={`/${chain.registryName}/${multisigInfo.address}/transaction/new`}>
|
||||
Create new transaction
|
||||
</a>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Holdings</CardTitle>
|
||||
<CardDescription>
|
||||
This multisig's list of tokens on {chain.chainDisplayName || "Cosmos Hub"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BalancesTable walletAddress={multisigInfo.address} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +1,36 @@
|
||||
import { isChainInfoFilled } from "@/context/ChainsContext/helpers";
|
||||
import { MultisigThresholdPubkey, SinglePubkey } from "@cosmjs/amino";
|
||||
import { Account, StargateClient } from "@cosmjs/stargate";
|
||||
import { Coin } from "cosmjs-types/cosmos/base/v1beta1/coin";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import HashView from "../../../components/dataViews/HashView";
|
||||
import MultisigHoldings from "../../../components/dataViews/MultisigHoldings";
|
||||
import MultisigMembers from "../../../components/dataViews/MultisigMembers";
|
||||
import Button from "../../../components/inputs/Button";
|
||||
import Page from "../../../components/layout/Page";
|
||||
import StackableContainer from "../../../components/layout/StackableContainer";
|
||||
import { useChains } from "../../../context/ChainsContext";
|
||||
import { explorerLinkAccount } from "../../../lib/displayHelpers";
|
||||
import { getMultisigAccount } from "../../../lib/multisigHelpers";
|
||||
import MultisigView from "@/components/dataViews/MultisigView";
|
||||
import Head from "@/components/head";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { useChains } from "@/context/ChainsContext";
|
||||
import Link from "next/link";
|
||||
|
||||
function participantPubkeysFromMultisig(
|
||||
multisig: MultisigThresholdPubkey,
|
||||
): readonly SinglePubkey[] {
|
||||
return multisig.value.pubkeys;
|
||||
}
|
||||
|
||||
const Multipage = () => {
|
||||
const router = useRouter();
|
||||
export default function MultisigPage() {
|
||||
const { chain } = useChains();
|
||||
|
||||
const [holdings, setHoldings] = useState<readonly Coin[]>([]);
|
||||
const [accountOnChain, setAccountOnChain] = useState<Account | null>(null);
|
||||
const [pubkey, setPubkey] = useState<MultisigThresholdPubkey>();
|
||||
const [hasAccountError, setHasAccountError] = useState(false);
|
||||
|
||||
const multisigAddress = router.query.address?.toString();
|
||||
const explorerLink = explorerLinkAccount(chain.explorerLinks.account, multisigAddress || "");
|
||||
|
||||
useEffect(() => {
|
||||
(async function fetchMultisig() {
|
||||
try {
|
||||
if (!multisigAddress || !isChainInfoFilled(chain) || !chain.nodeAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await StargateClient.connect(chain.nodeAddress);
|
||||
|
||||
const tempHoldings = await client.getAllBalances(multisigAddress);
|
||||
setHoldings(tempHoldings);
|
||||
|
||||
const result = await getMultisigAccount(multisigAddress, chain.addressPrefix, client);
|
||||
setPubkey(result[0]);
|
||||
setAccountOnChain(result[1]);
|
||||
setHasAccountError(false);
|
||||
} catch (error: unknown) {
|
||||
setHasAccountError(true);
|
||||
console.error(
|
||||
error instanceof Error ? error.message : "Multisig address could not be found",
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [chain, multisigAddress]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
goBack={
|
||||
chain.registryName ? { pathname: `/${chain.registryName}`, title: "home" } : undefined
|
||||
}
|
||||
>
|
||||
<StackableContainer base>
|
||||
<StackableContainer>
|
||||
<label>Multisig Address</label>
|
||||
<h1>{multisigAddress ? <HashView hash={multisigAddress} /> : "No Address"}</h1>
|
||||
{explorerLink ? <Button href={explorerLink} label="View in Explorer"></Button> : null}
|
||||
</StackableContainer>
|
||||
{pubkey ? (
|
||||
<MultisigMembers
|
||||
members={participantPubkeysFromMultisig(pubkey)}
|
||||
addressPrefix={chain.addressPrefix}
|
||||
threshold={pubkey.value.threshold}
|
||||
/>
|
||||
) : null}
|
||||
<MultisigHoldings holdings={holdings} />
|
||||
{hasAccountError || !accountOnChain ? (
|
||||
<StackableContainer>
|
||||
<div className="multisig-error">
|
||||
{hasAccountError ? (
|
||||
<>
|
||||
<p>
|
||||
This multisig address's pubkeys are not available, and so it cannot be used with
|
||||
this tool.
|
||||
</p>
|
||||
<p>
|
||||
You can recreate it with this tool here, or sign and broadcast a transaction
|
||||
with the tool you used to create it. Either option will make the pubkeys
|
||||
accessible and will allow this tool to use this multisig fully.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
{!!accountOnChain ? (
|
||||
<p>
|
||||
An account needs to be present on chain before creating a transaction. Send some
|
||||
tokens to the address first.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</StackableContainer>
|
||||
) : null}
|
||||
<Button
|
||||
label="Create New Transaction"
|
||||
onClick={() => router.push(`/${chain.registryName}/${multisigAddress}/transaction/new`)}
|
||||
disabled={!accountOnChain || !multisigAddress}
|
||||
/>
|
||||
</StackableContainer>
|
||||
<style jsx>{`
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
p {
|
||||
margin-top: 15px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.multisig-error p {
|
||||
max-width: 550px;
|
||||
color: red;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.multisig-error p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
`}</style>
|
||||
</Page>
|
||||
<div className="m-4 mt-8 flex max-w-xl flex-1 flex-col justify-center gap-4">
|
||||
<Head title={`${chain.chainDisplayName || "Cosmos Hub"} Multisig Manager`} />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink>
|
||||
{chain.registryName ? <Link href={`/${chain.registryName}`}>Home</Link> : null}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Multisig</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<MultisigView />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Multipage;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import FindMultisigForm from "@/components/forms/FindMultisigForm";
|
||||
import Head from "@/components/head";
|
||||
import { useChains } from "@/context/ChainsContext";
|
||||
|
||||
const MultiPage = () => {
|
||||
const FindMultisigPage = () => {
|
||||
const { chain } = useChains();
|
||||
|
||||
return (
|
||||
@@ -13,4 +13,4 @@ const MultiPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiPage;
|
||||
export default FindMultisigPage;
|
||||
|
||||
Reference in New Issue
Block a user