Skip to content

Commit

Permalink
feature: Multisig wallet
Browse files Browse the repository at this point in the history
  • Loading branch information
sim committed Jan 13, 2022
1 parent 621bd69 commit 030966d
Show file tree
Hide file tree
Showing 32 changed files with 1,032 additions and 101 deletions.
6 changes: 6 additions & 0 deletions src/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import StoreCodeTx from "txs/wasm/StoreCodeTx"
import InstantiateContractTx from "txs/wasm/InstantiateContractTx"
import ExecuteContractTx from "txs/wasm/ExecuteContractTx"
import AnchorEarnTx from "txs/earn/AnchorEarnTx"
import SignMultisigTxPage from "pages/multisig/SignMultisigTxPage"
import PostMultisigTxPage from "pages/multisig/PostMultisigTxPage"

/* auth */
import Auth from "auth/modules/Auth"
Expand Down Expand Up @@ -106,6 +108,10 @@ export const useNav = () => {
{ path: "/validator/:address", element: <ValidatorDetails /> },
{ path: "/proposal/:id", element: <ProposalDetails /> },

/* multisig */
{ path: "/multisig/sign", element: <SignMultisigTxPage /> },
{ path: "/multisig/post", element: <PostMultisigTxPage /> },

/* txs */
{ path: "/send", element: <SendTx /> },
{ path: "/nft/transfer", element: <TransferCW721Tx /> },
Expand Down
13 changes: 10 additions & 3 deletions src/app/sections/Connected.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet"
import GroupsIcon from "@mui/icons-material/Groups"
import { truncate } from "@terra.kitchen/utils"
import { useWallet } from "@terra-money/wallet-provider"
import { useAddress } from "data/wallet"
import { Button, Copy, FinderLink } from "components/general"
import { Grid } from "components/layout"
import { Tooltip, Popover } from "components/display"
import { useAuth } from "auth"
import { isWallet, useAuth } from "auth"
import SwitchWallet from "auth/modules/select/SwitchWallet"
import PopoverNone from "../components/PopoverNone"
import styles from "./Connected.module.scss"
Expand Down Expand Up @@ -54,11 +55,17 @@ const Connected = () => {
theme="none"
>
<Button
icon={<AccountBalanceWalletIcon style={{ fontSize: 16 }} />}
icon={
isWallet.multisig(wallet) ? (
<GroupsIcon style={{ fontSize: 16 }} />
) : (
<AccountBalanceWalletIcon style={{ fontSize: 16 }} />
)
}
size="small"
outline
>
{wallet && "name" in wallet ? wallet.name : truncate(address)}
{isWallet.local(wallet) ? wallet.name : truncate(address)}
</Button>
</Popover>
)
Expand Down
15 changes: 11 additions & 4 deletions src/auth/auth.d.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
type Bip = 118 | 330

interface Wallet {
name: string
type Wallet = SingleWallet | MultisigWallet | LedgerWallet
type LocalWallet = SingleWallet | MultisigWallet // wallet with name

interface SingleWallet {
address: string
name: string
}

interface MultisigWallet extends SingleWallet {
multisig: true
}

interface LedgerWallet {
address: string
ledger: true
}

interface StoredWallet extends Wallet {
interface StoredWallet extends SingleWallet {
encrypted: string
}

interface StoredWalletLegacy extends Wallet {
interface StoredWalletLegacy extends SingleWallet {
wallet: string
}
7 changes: 7 additions & 0 deletions src/auth/components/MultisigBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import GroupsIcon from "@mui/icons-material/Groups"

const MultisigBadge = () => {
return <GroupsIcon fontSize="small" />
}

export default MultisigBadge
58 changes: 50 additions & 8 deletions src/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useCallback, useMemo } from "react"
import { atom, useRecoilState } from "recoil"
import { encode } from "js-base64"
import { AccAddress, CreateTxOptions, isTxError } from "@terra-money/terra.js"
import { AccAddress, SignDoc } from "@terra-money/terra.js"
import { CreateTxOptions, Tx, isTxError } from "@terra-money/terra.js"
import { PublicKey, RawKey, SignatureV2 } from "@terra-money/terra.js"
import { useChainID } from "data/wallet"
import { useLCDClient } from "data/queries/lcdClient"
import is from "../scripts/is"
import { PasswordError } from "../scripts/keystore"
import { getDecryptedKey, testPassword } from "../scripts/keystore"
import { getWallet, storeWallet, clearWallet } from "../scripts/keystore"
Expand All @@ -29,8 +31,13 @@ const useAuth = () => {
/* connect | disconnect */
const connect = useCallback(
(name: string) => {
const { address } = getStoredWallet(name)
const wallet = { name, address }
const storedWallet = getStoredWallet(name)
const { address } = storedWallet

const wallet = is.multisig(storedWallet)
? { name, address, multisig: true }
: { name, address }

storeWallet(wallet)
setWallet(wallet)
},
Expand All @@ -53,7 +60,7 @@ const useAuth = () => {

/* helpers */
const connectedWallet = useMemo(() => {
if (!(wallet && "name" in wallet)) return
if (!is.local(wallet)) return
return wallet
}, [wallet])

Expand Down Expand Up @@ -101,17 +108,50 @@ const useAuth = () => {
/* tx */
const chainID = useChainID()

const sign = async (txOptions: CreateTxOptions, password = "") => {
const create = async (txOptions: CreateTxOptions) => {
if (!wallet) throw new Error("Wallet is not defined")
const { address } = wallet
return await lcd.tx.create([{ address }], txOptions)
}

const createSignature = async (
tx: Tx,
address: AccAddress,
password = ""
) => {
if (!wallet) throw new Error("Wallet is not defined")

const accountInfo = await lcd.auth.accountInfo(address)

const doc = new SignDoc(
lcd.config.chainID,
accountInfo.getAccountNumber(),
accountInfo.getSequenceNumber(),
tx.auth_info,
tx.body
)

if (is.ledger(wallet)) {
const key = await getLedgerKey()
return await key.createSignatureAmino(doc)
} else {
const pk = getKey(password)
if (!pk) throw new PasswordError("Incorrect password")
const key = new RawKey(Buffer.from(pk, "hex"))
return await key.createSignatureAmino(doc)
}
}

const sign = async (txOptions: CreateTxOptions, password = "") => {
if (!wallet) throw new Error("Wallet is not defined")

if ("ledger" in wallet) {
if (is.ledger(wallet)) {
const key = await getLedgerKey()
const wallet = lcd.wallet(key)
const { account_number: accountNumber, sequence } =
await wallet.accountNumberAndSequence()
const signMode = SignatureV2.SignMode.SIGN_MODE_LEGACY_AMINO_JSON
const unsignedTx = await lcd.tx.create([{ address }], txOptions)
const unsignedTx = await create(txOptions)
const options = { chainID, accountNumber, sequence, signMode }
return await key.signTx(unsignedTx, options)
} else {
Expand All @@ -126,7 +166,7 @@ const useAuth = () => {
const signBytes = (bytes: Buffer, password = "") => {
if (!wallet) throw new Error("Wallet is not defined")

if ("ledger" in wallet) {
if (is.ledger(wallet)) {
throw new Error("Ledger can not sign arbitrary data")
} else {
const pk = getKey(password)
Expand Down Expand Up @@ -161,6 +201,8 @@ const useAuth = () => {
available,
encodeEncryptedWallet,
validatePassword,
createSignature,
create,
signBytes,
sign,
post,
Expand Down
6 changes: 6 additions & 0 deletions src/auth/hooks/useAvailable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"
import SettingsBackupRestoreIcon from "@mui/icons-material/SettingsBackupRestore"
import KeyIcon from "@mui/icons-material/Key"
import GroupsIcon from "@mui/icons-material/Groups"
import { sandbox } from "../scripts/env"

const useAvailable = () => {
Expand All @@ -25,6 +26,11 @@ const useAvailable = () => {
children: t("Import wallet"),
icon: <KeyIcon />,
},
{
to: "/auth/multisig/new",
children: t("New multisig wallet"),
icon: <GroupsIcon />,
},
]
}

Expand Down
1 change: 1 addition & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as useAuth } from "./hooks/useAuth"
export { default as isWallet } from "./scripts/is"
2 changes: 2 additions & 0 deletions src/auth/modules/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AccessWithLedgerPage from "../ledger/AccessWithLedgerPage"
import NewWalletPage from "./create/NewWalletPage"
import RecoverWalletPage from "./create/RecoverWalletPage"
import ImportWalletPage from "./create/ImportWalletPage"
import NewMultisigWalletPage from "./create/NewMultisigWalletPage"

/* manage */
import ManageWallets from "./manage/ManageWallets"
Expand All @@ -27,6 +28,7 @@ const Auth = () => {
<Route path="new" element={<NewWalletPage />} />
<Route path="recover" element={<RecoverWalletPage />} />
<Route path="import" element={<ImportWalletPage />} />
<Route path="multisig/new" element={<NewMultisigWalletPage />} />

{/* manage */}
<Route path="export" element={<ExportWalletPage />} />
Expand Down
152 changes: 152 additions & 0 deletions src/auth/modules/create/CreateMultisigWalletForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { useFieldArray, useForm } from "react-hook-form"
import axios from "axios"
import AddIcon from "@mui/icons-material/Add"
import RemoveIcon from "@mui/icons-material/Remove"
import { AccAddress, SimplePublicKey } from "@terra-money/terra.js"
import { LegacyAminoMultisigPublicKey } from "@terra-money/terra.js"
import { SAMPLE_ADDRESS } from "config/constants"
import { getErrorMessage } from "utils/error"
import { useLCDClient } from "data/queries/lcdClient"
import { Grid } from "components/layout"
import { Form, FormGroup, FormItem } from "components/form"
import { FormError, FormWarning } from "components/form"
import { Input, Submit, Paste } from "components/form"
import validate from "../../scripts/validate"

interface Values {
addresses: { value: AccAddress }[]
threshold: number
}

interface Props {
onCreated: (publicKey: LegacyAminoMultisigPublicKey) => void
}

const CreateMultisigWalletForm = ({ onCreated }: Props) => {
const { t } = useTranslation()
const lcd = useLCDClient()

/* form */
const defaultValues = {
addresses: [{ value: "" }, { value: "" }, { value: "" }],
threshold: 2,
}

const form = useForm<Values>({ mode: "onChange", defaultValues })

const { register, control, handleSubmit, formState } = form
const { errors, isValid } = formState

const fieldArray = useFieldArray({ control, name: "addresses" })
const { fields, append, remove } = fieldArray

const [error, setError] = useState<Error>()

const paste = async (lines: string[]) => {
const values = lines.filter(AccAddress.validate).map((value) => ({ value }))
if (values.length) fieldArray.replace(values)
}

/* query */
const getPublicKey = async (address: AccAddress) => {
const accountInfo = await lcd.auth.accountInfo(address)
const publicKey = accountInfo.getPublicKey()
if (!publicKey) throw new Error(`Public key is null: ${address}`)
return publicKey
}

const getPublicKeys = async (addresses: AccAddress[]) => {
const results = await Promise.allSettled(addresses.map(getPublicKey))

return results.map((result) => {
if (result.status === "rejected") {
const message = axios.isAxiosError(result.reason)
? getErrorMessage(result.reason)
: result.reason

throw new Error(message)
}

return result.value as SimplePublicKey
})
}

/* submit */
const [submitting, setSubmitting] = useState(false)

const submit = async ({ addresses, threshold }: Values) => {
setSubmitting(true)

try {
const values = addresses.map(({ value }) => value)
const publicKeys = await getPublicKeys(values)
const publicKey = new LegacyAminoMultisigPublicKey(threshold, publicKeys)
onCreated(publicKey)
} catch (error) {
setError(error as Error)
}

setSubmitting(false)
}

/* render */
const length = fields.length
return (
<Form onSubmit={handleSubmit(submit)}>
<Grid gap={4}>
<FormWarning>
{t(
"A new multisig wallet is created when the order of addresses or the threshold change"
)}
</FormWarning>
<FormWarning>{t("Participants must have coins")}</FormWarning>
</Grid>

<FormItem label={t("Address")} extra={<Paste paste={paste} />}>
{fields.map(({ id }, index) => (
<FormGroup
button={
length - 1 === index
? {
onClick: () => append({ value: "" }),
children: <AddIcon style={{ fontSize: 18 }} />,
}
: {
onClick: () => remove(index),
children: <RemoveIcon style={{ fontSize: 18 }} />,
}
}
key={id}
>
<FormItem>
<Input
{...register(`addresses.${index}.value`, {
validate: AccAddress.validate,
})}
placeholder={SAMPLE_ADDRESS}
/>
</FormItem>
</FormGroup>
))}
</FormItem>

<FormItem label={t("Threshold")} error={errors.threshold?.message}>
<Input
{...register("threshold", {
valueAsNumber: true,
validate: validate.index,
})}
placeholder={String(Math.ceil(length / 2))}
/>
</FormItem>

{error && <FormError>{error.message}</FormError>}

<Submit submitting={submitting} disabled={!isValid} />
</Form>
)
}

export default CreateMultisigWalletForm
Loading

0 comments on commit 030966d

Please sign in to comment.