Merge pull request #213 from cosmos/feat/new-create-multisig
New create multisig view
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user