Merge pull request #213 from cosmos/feat/new-create-multisig

New create multisig view
This commit is contained in:
Abel Fernández
2024-06-10 18:25:27 +02:00
committed by GitHub
8 changed files with 534 additions and 273 deletions
@@ -0,0 +1,77 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useChains } from "@/context/ChainsContext";
import { ReloadIcon } from "@radix-ui/react-icons";
import { Info } from "lucide-react";
import { UseFormReturn, useFormState } from "react-hook-form";
interface ConfirmCreateMultisigProps {
readonly createMultisigForm: UseFormReturn<{ members: { member: string }[]; threshold: number }>;
}
export default function ConfirmCreateMultisig({ createMultisigForm }: ConfirmCreateMultisigProps) {
const { chain } = useChains();
const { isValid, isSubmitting, isSubmitted } = useFormState(createMultisigForm);
const { members, threshold } = createMultisigForm.getValues();
const loading = isSubmitting || isSubmitted;
return (
<Dialog>
<DialogTrigger asChild>
<Button
onClick={(e) => {
createMultisigForm.trigger();
if (!isValid) {
e.preventDefault();
}
}}
>
Submit
</Button>
</DialogTrigger>
<DialogContent className={"overflow-y-auto bg-fuchsia-900"} style={{ width: "auto" }}>
<DialogTitle>Create a new multisig on "{chain.chainDisplayName}"?</DialogTitle>
<h4 className="font-bold">Members</h4>
<div className="flex flex-col gap-2">
{members
.filter(({ member }) => member !== "")
.map(({ member }) => (
<div
key={member}
className="flex items-center space-x-2 rounded-md border p-2 transition-colors"
>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">{member}</p>
</div>
</div>
))}
</div>
<div className="flex items-center gap-1 text-sm text-secondary">
<Info className="text-secondary" />
<p>
{threshold} {threshold === 1 ? "signature" : "signatures"} needed to send a transaction.
</p>
</div>
<div className="flex gap-4">
<DialogClose asChild>
<Button variant="secondary" className="mt-4" disabled={loading}>
Cancel
</Button>
</DialogClose>
<Button type="submit" form="create-multisig-form" className="mt-4" disabled={loading}>
{loading ? <ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> : null}
Create multisig
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,70 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ClipboardEventHandler } from "react";
import { UseFieldArrayReplace, UseFormReturn } from "react-hook-form";
import { useChains } from "../../../context/ChainsContext";
import { exampleAddress, examplePubkey } from "../../../lib/displayHelpers";
interface MemberFormFieldProps {
readonly createMultisigForm: UseFormReturn<{ members: { member: string }[]; threshold: number }>;
readonly index: number;
readonly membersReplace: UseFieldArrayReplace<
{ members: { member: string }[]; threshold: number },
"members"
>;
}
export default function MemberFormField({
createMultisigForm,
index,
membersReplace,
}: MemberFormFieldProps) {
const { chain } = useChains();
const onPaste: ClipboardEventHandler<HTMLInputElement> = (ev) => {
const rawData = ev.clipboardData.getData("text");
const csv = rawData.split(",");
const finalValues =
csv.length > 1
? csv.map((el) => el.trim()).filter((el) => el !== "")
: rawData
.replace(/\n/g, " ")
.split(" ")
.map((el) => el.trim())
.filter((el) => el !== "");
membersReplace(finalValues.map((el) => ({ member: el })));
ev.preventDefault();
};
return (
<FormField
control={createMultisigForm.control}
name={`members.${index}.member`}
render={() => (
<FormItem>
<FormLabel>Member #{index + 1}</FormLabel>
<FormDescription>Address or public key</FormDescription>
<FormControl>
<Input
placeholder={`E.g. "${
index % 2 === 0 ? exampleAddress(index, chain.addressPrefix) : examplePubkey(index)
}"`}
onPaste={index === 0 ? onPaste : undefined}
{...createMultisigForm.register(`members.${index}.member`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
@@ -0,0 +1,130 @@
import { ChainInfo } from "@/context/ChainsContext/types";
import { pubkeyToAddress } from "@cosmjs/amino";
import { StargateClient } from "@cosmjs/stargate";
import { z } from "zod";
import { checkAddressOrPubkey } from "../../../lib/displayHelpers";
export const getCreateMultisigSchema = (chain: ChainInfo) =>
z
.object({
members: z.array(
z.object({
member: z
.string()
.trim()
.superRefine(async (member, ctx) => {
if (!member) {
return z.NEVER;
}
const addressOrPubkeyError = checkAddressOrPubkey(member, chain.addressPrefix);
if (addressOrPubkeyError) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: addressOrPubkeyError });
} else {
try {
const address = member.startsWith(chain.addressPrefix)
? member
: pubkeyToAddress(
{ type: "tendermint/PubKeySecp256k1", value: member },
chain.addressPrefix,
);
const client = await StargateClient.connect(chain.nodeAddress);
const accountOnChain = await client.getAccount(address);
if (!accountOnChain || !accountOnChain.pubkey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This account needs to send a transaction to appear on chain",
});
}
} catch {
return z.NEVER;
}
}
}),
}),
),
threshold: z.coerce
.number({ invalid_type_error: "Threshold must be a number" })
.int("Threshold can't have decimals")
.min(1, "Threshold must be at least 1"),
})
.superRefine(({ members }, ctx) => {
if (members.length !== 2) {
return;
}
const firstEmptyMemberIndex = members.findIndex(({ member }) => member.trim() === "");
if (firstEmptyMemberIndex !== -1) {
const issue = {
code: z.ZodIssueCode.custom,
message: "At least 2 members needed",
path: [`members.${firstEmptyMemberIndex}.member`],
};
ctx.addIssue(issue);
}
})
.superRefine(({ members }, ctx) => {
const addresses = members.map(({ member }) => {
if (!member.startsWith(chain.addressPrefix)) {
try {
const address = pubkeyToAddress(
{ type: "tendermint/PubKeySecp256k1", value: member },
chain.addressPrefix,
);
return address;
} catch {}
}
return member;
});
const dupedAddresses = addresses.filter((member, i) => addresses.indexOf(member) !== i);
const dupedAddressesIndexes: number[][] = [];
for (const dupedAddress of dupedAddresses) {
const dupedIndexes = [];
for (let i = 0; i < addresses.length; ++i) {
const index = addresses.indexOf(dupedAddress, i);
if (index !== -1) {
dupedIndexes.push(index);
}
}
dupedAddressesIndexes.push(dupedIndexes.sort());
}
if (dupedAddressesIndexes.length) {
for (const dupedIndexes of dupedAddressesIndexes) {
for (const duplicateIndex of dupedIndexes) {
const issue = {
code: z.ZodIssueCode.custom,
message: `Members cannot be duplicate (${dupedIndexes
.map((index) => `#${index + 1}`)
.join(", ")})`,
path: [`members.${duplicateIndex}.member`],
};
ctx.addIssue(issue);
}
}
} else {
return z.NEVER;
}
})
.refine(
({ members, threshold }) => threshold <= members.filter(({ member }) => member !== "").length,
({ members }) => ({
message: `Threshold can't be higher than the number of members (${
members.filter(({ member }) => member !== "").length
})`,
path: ["threshold"],
}),
);
@@ -0,0 +1,174 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toastError } from "@/lib/utils";
import { StargateClient } from "@cosmjs/stargate";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { useChains } from "../../../context/ChainsContext";
import { createMultisigFromCompressedSecp256k1Pubkeys } from "../../../lib/multisigHelpers";
import ConfirmCreateMultisig from "./ConfirmCreateMultisig";
import MemberFormField from "./MemberFormField";
import { getCreateMultisigSchema } from "./formSchema";
export default function CreateMultisigForm() {
const router = useRouter();
const { chain } = useChains();
const createMultisigSchema = getCreateMultisigSchema(chain);
const createMultisigForm = useForm<z.infer<typeof createMultisigSchema>>({
resolver: zodResolver(createMultisigSchema),
defaultValues: { members: [{ member: "" }, { member: "" }], threshold: 2 },
});
const {
fields: membersFields,
append: membersAppend,
remove: membersRemove,
replace: membersReplace,
} = useFieldArray({ name: "members", control: createMultisigForm.control });
const watchedMembers = useWatch({ control: createMultisigForm.control, name: "members" });
useEffect(() => {
if (watchedMembers.every(({ member }) => member !== "")) {
membersAppend({ member: "" }, { shouldFocus: false });
createMultisigForm.trigger();
}
if (
watchedMembers.length > 2 &&
watchedMembers.filter(({ member }) => member === "").length > 1
) {
const memberToRemove = watchedMembers.findIndex(({ member }) => member === "");
membersRemove(memberToRemove);
createMultisigForm.trigger();
}
}, [createMultisigForm, membersAppend, membersRemove, watchedMembers]);
useEffect(() => {
const numMembers = watchedMembers.filter(({ member }) => member !== "").length;
createMultisigForm.setValue("threshold", Math.max(2, numMembers));
}, [createMultisigForm, watchedMembers]);
const submitCreateMultisig = async () => {
// Caution: threshold is string instead of number
const { members, threshold } = createMultisigForm.getValues();
const pubkeys = await Promise.all(
members
.filter(({ member }) => member !== "")
.map(async ({ member }) => {
try {
if (!member.startsWith(chain.addressPrefix)) {
return member;
}
const client = await StargateClient.connect(chain.nodeAddress);
const accountOnChain = await client.getAccount(member);
if (!accountOnChain || !accountOnChain.pubkey) {
return member;
}
return String(accountOnChain.pubkey.value);
} catch {
return member;
}
}),
);
try {
const multisigAddress = await createMultisigFromCompressedSecp256k1Pubkeys(
pubkeys,
Number(threshold),
chain.addressPrefix,
chain.chainId,
);
router.push(`/${chain.registryName}/${multisigAddress}`);
} catch (e) {
console.error("Failed to create multisig:", e);
toastError({
description: "Failed to create multisig",
fullError: e instanceof Error ? e : undefined,
});
}
};
return (
<>
<Card>
<CardHeader>
<CardTitle>Create multisig</CardTitle>
<CardDescription>
<p className="mt-2">
Fill the form to create a new multisig account on{" "}
{chain.chainDisplayName || "Cosmos Hub"}.
</p>
<p className="mt-2">
You can paste several addresses on the first input if they are separated by whitespace
or commas.
</p>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...createMultisigForm}>
<form
id="create-multisig-form"
onSubmit={createMultisigForm.handleSubmit(submitCreateMultisig)}
className="space-y-4"
>
{membersFields.map((arrayField, index) => (
<MemberFormField
key={arrayField.id}
createMultisigForm={createMultisigForm}
index={index}
membersReplace={membersReplace}
/>
))}
<FormField
control={createMultisigForm.control}
name="threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Threshold</FormLabel>
<FormDescription>
Number of signatures needed to broadcast a transaction
</FormDescription>
<FormControl className="">
<div className="flex items-center gap-2">
<Input className="w-20" placeholder="2" {...field} />
<span className="text-sm text-muted-foreground">
out of{" "}
<em className="text-base font-bold not-italic text-white">
{watchedMembers.filter(({ member }) => member !== "").length}
</em>{" "}
members
</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ConfirmCreateMultisig createMultisigForm={createMultisigForm} />
</form>
</Form>
</CardContent>
</Card>
</>
);
}
-245
View File
@@ -1,245 +0,0 @@
import { toastError } from "@/lib/utils";
import { StargateClient } from "@cosmjs/stargate";
import { NextRouter, withRouter } from "next/router";
import { useState } from "react";
import { useChains } from "../../context/ChainsContext";
import { exampleAddress, examplePubkey } from "../../lib/displayHelpers";
import { createMultisigFromCompressedSecp256k1Pubkeys } from "../../lib/multisigHelpers";
import Button from "../inputs/Button";
import Input from "../inputs/Input";
import ThresholdInput from "../inputs/ThresholdInput";
import StackableContainer from "../layout/StackableContainer";
const emptyPubKeyGroup = () => {
return { address: "", compressedPubkey: "", keyError: "", isPubkey: false };
};
interface Props {
router: NextRouter;
}
const MultiSigForm = (props: Props) => {
const { chain } = useChains();
const [pubkeys, setPubkeys] = useState([emptyPubKeyGroup(), emptyPubKeyGroup()]);
const [threshold, setThreshold] = useState(2);
const [processing, setProcessing] = useState(false);
const handleChangeThreshold = (e: React.ChangeEvent<HTMLInputElement>) => {
let newThreshold = parseInt(e.target.value, 10);
if (newThreshold > pubkeys.length || newThreshold <= 0) {
newThreshold = threshold;
}
setThreshold(newThreshold);
};
const handleKeyGroupChange = (index: number, e: React.ChangeEvent<HTMLInputElement>) => {
const tempPubkeys = [...pubkeys];
if (e.target.name === "compressedPubkey") {
tempPubkeys[index].compressedPubkey = e.target.value;
} else if (e.target.name === "address") {
tempPubkeys[index].address = e.target.value;
}
setPubkeys(tempPubkeys);
};
const handleAddKey = () => {
const tempPubkeys = [...pubkeys];
setPubkeys(tempPubkeys.concat(emptyPubKeyGroup()));
};
const handleRemove = (index: number) => {
const tempPubkeys = [...pubkeys];
const oldLength = tempPubkeys.length;
tempPubkeys.splice(index, 1);
const newThreshold = threshold > tempPubkeys.length ? tempPubkeys.length : oldLength;
setPubkeys(tempPubkeys);
setThreshold(newThreshold);
};
const getPubkeyFromNode = async (address: string) => {
const client = await StargateClient.connect(chain.nodeAddress);
const accountOnChain = await client.getAccount(address);
console.log(accountOnChain);
if (!accountOnChain || !accountOnChain.pubkey) {
throw new Error(
"Account has no pubkey on chain, this address will need to send a transaction to appear on chain.",
);
}
return accountOnChain.pubkey.value;
};
const handleKeyBlur = async (index: number, { target }: React.ChangeEvent<HTMLInputElement>) => {
try {
const tempPubkeys = [...pubkeys];
let pubkey;
// use pubkey
console.log(tempPubkeys[index]);
if (tempPubkeys[index].isPubkey) {
pubkey = target.value;
if (pubkey.length !== 44) {
throw new Error("Invalid Secp256k1 pubkey");
}
} else {
// use address to fetch pubkey
const address = target.value;
if (address.length > 0) {
pubkey = await getPubkeyFromNode(address);
}
}
tempPubkeys[index].compressedPubkey = pubkey;
tempPubkeys[index].keyError = "";
setPubkeys(tempPubkeys);
} catch (e) {
console.error("Invalid address or pubkey", e);
const tempPubkeys = [...pubkeys];
tempPubkeys[index].keyError = e instanceof Error ? e.message : "Invalid address or pubkey";
setPubkeys(tempPubkeys);
}
};
const handleCreate = async () => {
setProcessing(true);
const compressedPubkeys = pubkeys.map((item) => item.compressedPubkey);
let multisigAddress;
try {
multisigAddress = await createMultisigFromCompressedSecp256k1Pubkeys(
compressedPubkeys,
threshold,
chain.addressPrefix,
chain.chainId,
);
props.router.push(`/${chain.registryName}/${multisigAddress}`);
} catch (e) {
console.error("Failed to create multisig:", e);
toastError({
description: "Failed to create multisig",
fullError: e instanceof Error ? e : undefined,
});
}
};
const togglePubkey = (index: number) => {
const tempPubkeys = [...pubkeys];
tempPubkeys[index].isPubkey = !tempPubkeys[index].isPubkey;
setPubkeys(tempPubkeys);
};
return (
<>
<StackableContainer>
<StackableContainer lessPadding>
<p>Add the addresses that will make up this multisig.</p>
</StackableContainer>
{pubkeys.map((pubkeyGroup, index) => {
return (
<StackableContainer lessPadding lessMargin key={index}>
<div className="key-row">
{pubkeys.length > 2 && (
<button
className="remove"
onClick={() => {
handleRemove(index);
}}
>
</button>
)}
<div className="key-inputs">
<Input
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
handleKeyGroupChange(index, e);
}}
value={
pubkeyGroup.isPubkey ? pubkeyGroup.compressedPubkey : pubkeyGroup.address
}
label={pubkeyGroup.isPubkey ? "Public Key (Secp256k1)" : "Address"}
name={pubkeyGroup.isPubkey ? "compressedPubkey" : "address"}
width="100%"
placeholder={`E.g. ${
pubkeyGroup.isPubkey
? examplePubkey(index)
: exampleAddress(index, chain.addressPrefix)
}`}
error={pubkeyGroup.keyError}
onBlur={(e: React.ChangeEvent<HTMLInputElement>) => {
handleKeyBlur(index, e);
}}
/>
<button className="toggle-type" onClick={() => togglePubkey(index)}>
Use {pubkeyGroup.isPubkey ? "Address" : "Public Key"}
</button>
</div>
</div>
</StackableContainer>
);
})}
<Button label="Add another address" onClick={() => handleAddKey()} />
</StackableContainer>
<StackableContainer>
<StackableContainer lessPadding>
<ThresholdInput
onChange={handleChangeThreshold}
value={threshold}
total={pubkeys.length}
/>
</StackableContainer>
<StackableContainer lessPadding lessMargin>
<p>
This means that each transaction this multisig makes will require {threshold} of the
members to sign it for it to be accepted by the validators.
</p>
</StackableContainer>
</StackableContainer>
<Button primary onClick={handleCreate} label="Create Multisig" loading={processing} />
<style jsx>{`
.key-inputs {
display: flex;
flex-direction: column;
align-items: end;
justify-content: space-between;
max-width: 350px;
}
.error {
color: coral;
font-size: 0.8em;
text-align: left;
margin: 0.5em 0;
}
.key-row {
position: relative;
}
button.remove {
background: rgba(255, 255, 255, 0.2);
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
color: white;
position: absolute;
right: -23px;
top: -22px;
}
p {
margin-top: 1em;
}
p:first-child {
margin-top: 0;
}
.toggle-type {
margin-top: 10px;
font-size: 12px;
font-style: italic;
border: none;
background: none;
color: white;
text-decoration: underline;
}
`}</style>
</>
);
};
export default withRouter(MultiSigForm);
+4
View File
@@ -105,6 +105,9 @@ const DialogDescription = React.forwardRef<
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
const DialogClose = DialogPrimitive.DialogClose;
DialogClose.displayName = DialogPrimitive.DialogClose.displayName
export {
Dialog,
DialogTrigger,
@@ -113,4 +116,5 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose
}
+50 -10
View File
@@ -113,6 +113,25 @@ function examplePubkey(index: number): string {
return toBase64(data);
}
function digestAddressError(error: unknown) {
if (!(error instanceof Error)) {
return "Expected a bech32 address";
}
const msg = error.message.toLowerCase();
switch (true) {
case msg.includes("too short"):
return "Too short";
case msg.includes("no separator character"):
return "No separator character found";
case msg.includes("invalid checksum"):
return "Invalid checksum";
default:
return error.message;
}
}
/**
* Returns an error message for invalid addresses.
* Returns null of there is no error.
@@ -126,9 +145,8 @@ const checkAddress = (input: string, chainAddressPrefix: string | null) => {
let prefix;
try {
({ data, prefix } = fromBech32(input));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
return error.toString();
} catch (error: unknown) {
return digestAddressError(error);
}
if (chainAddressPrefix) {
@@ -146,6 +164,27 @@ const checkAddress = (input: string, chainAddressPrefix: string | null) => {
return null;
};
/**
* Returns an error message for invalid addresses or pubkeys.
* Returns null of there is no error.
*/
const checkAddressOrPubkey = (input: string, chainAddressPrefix: string) => {
if (!input) {
return "Empty";
}
if (!input.startsWith(chainAddressPrefix)) {
try {
fromBase64(input);
return null;
} catch {
return "Public key should be valid Base64";
}
}
return checkAddress(input, chainAddressPrefix);
};
/**
* Returns a link to a transaction in an explorer if an explorer is configured
* for transactions. Returns null otherwise.
@@ -183,16 +222,17 @@ const trimStringsObj = <StringsObj extends Record<string, string>>(obj: StringsO
};
export {
thinSpace,
capitalizeFirstLetter,
checkAddress,
checkAddressOrPubkey,
ellideMiddle,
exampleAddress,
examplePubkey,
exampleValidatorAddress,
explorerLinkAccount,
explorerLinkTx,
printableCoin,
printableCoins,
exampleAddress,
exampleValidatorAddress,
examplePubkey,
checkAddress,
explorerLinkTx,
explorerLinkAccount,
thinSpace,
trimStringsObj,
};
+29 -18
View File
@@ -1,25 +1,36 @@
import MultisigForm from "@/components/forms/MultisigForm";
import Page from "@/components/layout/Page";
import StackableContainer from "@/components/layout/StackableContainer";
import CreateMultisigForm from "@/components/forms/CreateMultisigForm";
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";
export default function CreatePage() {
export default function CreateMultisigPage() {
const { chain } = useChains();
return (
<Page
goBack={
chain.registryName
? { pathname: `/${chain.registryName}`, title: "home", needsConfirm: true }
: undefined
}
>
<StackableContainer base>
<StackableContainer lessPadding>
<h1 className="title">Create Legacy Multisig</h1>
</StackableContainer>
<MultisigForm />
</StackableContainer>
</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 asChild>
<Link href={`/${chain.registryName || ""}`}>Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create multisig</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<CreateMultisigForm />
</div>
);
}