diff --git a/public/locales/en.json b/public/locales/en.json index e3da0a0..10ec545 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -861,11 +861,25 @@ "errorNameRequired": "At least one name is required.", "errorWalletGone": "That wallet no longer exists.", - "errorWalletDecrypt": "Your wallet could not be decrypted.", + "errorWalletDecrypt": "The wallet \"{{address}}\" could not be decrypted.", "errorParameterNames": "Invalid names.", "errorParameterRecipient": "Invalid recipient.", - "errorNameNotFound": "The name \"{{name}}\" could not be found.", - "errorUnknown": "Unknown error transferring names. See console for details." + "errorNameNotFound": "One or more names could not be found.", + "errorNotNameOwner": "You are not the owner of one or more names.", + "errorUnknown": "Unknown error transferring names. See console for details.", + "errorNotificationTitle": "Name transfer failed", + + // Note that the zero and singular cases of these warnings will never be + // shown, but they're provided for i18n compatibility. + "warningMultipleNames": "Are you sure you want to transfer <1>{{count, number}} name to <3 />?", + "warningMultipleNames_plural": "Are you sure you want to transfer <1>{{count, number}} names to <3 />?", + "warningAllNames": "Are you sure you want to transfer <1>{{count, number}} name to <3 />? This is all your names!", + "warningAllNames_plural": "Are you sure you want to transfer <1>{{count, number}} names to <3 />? This is all your names!", + + "successMessage": "Name transferred successfully", + "successMessage_plural": "Names transferred successfully", + "successDescription": "Transferred <1>{{count, number}} name to <3 />.", + "successDescription_plural": "Transferred <1>{{count, number}} names to <3 />." }, "noNamesResult": { diff --git a/src/krist/api/AuthFailed.tsx b/src/krist/api/AuthFailed.tsx index b7db12c..6b83773 100644 --- a/src/krist/api/AuthFailed.tsx +++ b/src/krist/api/AuthFailed.tsx @@ -11,6 +11,13 @@ import * as api from "./"; +// Used to carry around information on which address failed auth +export class AuthFailedError extends api.APIError { + constructor(message: string, public address?: string) { + super(message); + } +} + interface AuthFailedModalHookResponse { authFailedModal: Omit; authFailedContextHolder: ReactElement; diff --git a/src/krist/api/names.ts b/src/krist/api/names.ts new file mode 100644 index 0000000..e01013a --- /dev/null +++ b/src/krist/api/names.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import * as api from "."; +import { AuthFailedError } from "@api/AuthFailed"; + +import { ValidDecryptedAddresses } from "@wallets"; + +import Debug from "debug"; +const debug = Debug("kristweb:api-names"); + +export async function transferNames( + decryptedAddresses: ValidDecryptedAddresses, + names: { name: string; owner: string }[], + recipient: string +): Promise { + for (const name of names) { + const { privatekey } = decryptedAddresses[name.owner]; + + try { + debug("transferring name %s from %s to %s", + name.name, name.owner, recipient); + + await api.post( + `/names/${encodeURIComponent(name.name)}/transfer`, + { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + address: recipient, + privatekey + }) + } + ); + } catch (err) { + // Convert auth_failed errors to AuthFailedError so the modal can display + // the correct address + if (err.message === "auth_failed") + throw new AuthFailedError(err.message, name.owner); + else + throw err; + } + } +} diff --git a/src/krist/wallets/functions/decryptWallet.ts b/src/krist/wallets/functions/decryptWallet.ts index 3c87a69..503bad6 100644 --- a/src/krist/wallets/functions/decryptWallet.ts +++ b/src/krist/wallets/functions/decryptWallet.ts @@ -3,7 +3,7 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { aesGcmDecrypt } from "@utils/crypto"; -import { Wallet } from ".."; +import { Wallet, WalletAddressMap } from ".."; export interface DecryptedWallet { password: string; privatekey: string } @@ -25,3 +25,46 @@ return null; } } + +export const DecryptErrorGone = Symbol("KWDecryptErrorGone"); +export const DecryptErrorFailed = Symbol("KWDecryptErrorFailed"); + +// TODO: use these in decryptWallet too (will require some refactoring) +export type DecryptResult = DecryptedWallet + | typeof DecryptErrorGone + | typeof DecryptErrorFailed; + +export type DecryptedAddresses = Record; +export type ValidDecryptedAddresses = Record; + +/** Decrypts an array of wallets by address at once. */ +export async function decryptAddresses( + masterPassword: string, + walletAddressMap: WalletAddressMap, + addresses: string[] +): Promise { + // Ensure the array of addresses is unique + const uniqAddresses = [...new Set(addresses)]; + const out: DecryptedAddresses = {}; + + // Try to decrypt each address + for (const address of uniqAddresses) { + // Find the wallet by address and verify it actually exists + const wallet = walletAddressMap[address]; + if (!wallet) { + out[address] = DecryptErrorGone; + continue; + } + + // Decrypt the wallet, erroring if it fails + const dec = await decryptWallet(masterPassword, wallet); + if (!dec) { + out[address] = DecryptErrorFailed; + continue; + } + + out[address] = dec; + } + + return out; +} diff --git a/src/pages/names/mgmt/NamePicker.tsx b/src/pages/names/mgmt/NamePicker.tsx index f4e71bd..8a47897 100644 --- a/src/pages/names/mgmt/NamePicker.tsx +++ b/src/pages/names/mgmt/NamePicker.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo, Dispatch, SetStateAction, Ref } from "react"; -import { Select, Form, Input, Button, notification } from "antd"; +import { Select, Form, Input, Button } from "antd"; import { RefSelectProps } from "antd/lib/select"; import { useTranslation, TFunction } from "react-i18next"; @@ -12,54 +12,25 @@ import { useSelector } from "react-redux"; import { RootState } from "@store"; -import { Wallet, WalletAddressMap, useWallets } from "@wallets"; -import { KristName } from "@api/types"; -import { lookupNames } from "@api/lookup"; +import { WalletAddressMap, useWallets } from "@wallets"; +import { NameOptionGroup, fetchNames } from "./lookupNames"; import { useNameSuffix } from "@utils/currency"; import shallowEqual from "shallowequal"; -import { throttle, groupBy } from "lodash-es"; +import { throttle } from "lodash-es"; import Debug from "debug"; const debug = Debug("kristweb:name-picker"); const FETCH_THROTTLE = 500; - -async function _fetchNames( +export async function _fetchNames( t: TFunction, nameSuffix: string, wallets: WalletAddressMap, setOptions: Dispatch> ): Promise { - debug("performing name fetch"); - - try { - // Get the full list of names for the given wallets. - const addresses = Object.keys(wallets); - const { names, total } = await lookupNames(addresses, { - orderBy: "name", order: "ASC", - limit: 1000 // TODO: support more than 1000 - }); - - // Since more than 1000 isn't supported yet, show a warning - if (total > 1000) - notification.warning({ message: t("namePicker.warningTotalLimit") }); - - // Group the names into OptGroups per wallet. - const options = Object.entries(groupBy(names, n => n.owner)) - .map(([address, group]) => - getNameOptions(nameSuffix, wallets[address], group)); - - debug("got names:", names, options); - setOptions(options); - } catch (err) { - setOptions(null); - notification.error({ - message: t("error"), - description: t("namePicker.errorLookup") - }); - } + setOptions(await fetchNames(t, nameSuffix, wallets)); } interface Props { @@ -98,7 +69,7 @@ // Used to fetch the list of available names const { walletAddressMap, joinedAddressList } = useWallets(); - const fetchNames = useMemo(() => + const throttledFetchNames = useMemo(() => throttle(_fetchNames, FETCH_THROTTLE, { leading: true }), []); const nameSuffix = useNameSuffix(); @@ -123,9 +94,9 @@ joinedAddressList, nameSuffix, refreshID ); - fetchNames(t, nameSuffix, walletAddressMap, setNameOptions); + throttledFetchNames(t, nameSuffix, walletAddressMap, setNameOptions); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetchNames, t, nameSuffix, refreshID, joinedAddressList]); + }, [throttledFetchNames, t, nameSuffix, refreshID, joinedAddressList]); // If passed an address, filter out that address from the results. Used to // prevent sending names to the existing owner. Renders the name options. @@ -236,37 +207,6 @@ ; } -interface NameOptionGroup { - key: string; - label: string; - names: NameOption[]; -} - -interface NameOption { - key: string; - value: string; - name: string; -} - -function getNameOptions( - nameSuffix: string, - wallet: Wallet, - names: KristName[] -): NameOptionGroup { - // Group by owning wallet - return { - key: wallet.address, - label: wallet.label || wallet.address, - - // Each individual name - names: names.map(name => ({ - key: name.name, - value: name.name, - name: name.name + "." + nameSuffix - })) - }; -} - function renderNameOptions(group: NameOptionGroup): JSX.Element { // Group by owning wallet return diff --git a/src/pages/names/mgmt/NameTransferModal.tsx b/src/pages/names/mgmt/NameTransferModal.tsx index aa856e3..20ec2e5 100644 --- a/src/pages/names/mgmt/NameTransferModal.tsx +++ b/src/pages/names/mgmt/NameTransferModal.tsx @@ -4,12 +4,30 @@ import { useState, Dispatch, SetStateAction } from "react"; import { Modal, Form, notification } from "antd"; -import { useTranslation } from "react-i18next"; +import { useTranslation, Trans } from "react-i18next"; +import { TranslatedError, translateError } from "@utils/i18n"; + +import { + useWallets, useMasterPasswordOnly, + decryptAddresses, DecryptErrorGone, DecryptErrorFailed, + ValidDecryptedAddresses +} from "@wallets"; +import { NameOption, fetchNames } from "./lookupNames"; +import { useNameSuffix } from "@utils/currency"; + +import { APIError } from "@api"; +import { transferNames } from "@api/names"; +import { useAuthFailedModal, AuthFailedError } from "@api/AuthFailed"; import { NamePicker } from "./NamePicker"; import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; import awaitTo from "await-to-js"; +import { groupBy } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("kristweb:name-transfer-modal"); interface FormValues { names: string[]; @@ -37,6 +55,101 @@ const [names, setNames] = useState(); const [recipient, setRecipient] = useState(); + // Confirmation modal used for when sending multiple names. + // This is created here to provide a translation context for the modal. + const [confirmModal, contextHolder] = Modal.useModal(); + // Modal used when auth fails + const { showAuthFailed, authFailedContextHolder } = useAuthFailedModal(); + // Context for translation in the success notification + const [notif, notifContextHolder] = notification.useNotification(); + + // Used to fetch the list of available names + const { walletAddressMap } = useWallets(); + const nameSuffix = useNameSuffix(); + // Used to decrypt the wallets for transfer + const masterPassword = useMasterPasswordOnly(); + + // Actually perform the bulk name transfer + async function handleSubmit(names: NameOption[], recipient: string) { + if (!masterPassword) return; + debug("submitting with names %o", names); + + // Attempt to decrypt each wallet. Group the names by wallet to create a + // LUT of decrypted privatekeys. + const nameOwners = names.map(n => n.owner); + const decryptResults = await decryptAddresses( + masterPassword, walletAddressMap, nameOwners + ); + + // Check if there were any decryption errors + if (Object.values(decryptResults).includes(DecryptErrorGone)) + throw new TranslatedError("nameTransfer.errorWalletGone"); + + const decryptFailed = Object.entries(decryptResults) + .find(([_, r]) => r === DecryptErrorFailed); + if (decryptFailed) { + throw new Error(t( + "nameTransfer.errorWalletDecrypt", + { address: decryptFailed[0] } + )); + } + + // Finally perform the transfer + await transferNames( + // We've already validated the names + decryptResults as ValidDecryptedAddresses, + names.map(n => ({ name: n.key, owner: n.owner })), + recipient + ); + + + // Success! Show notification and close modal + const count = names.length; + + notif.success({ + message: t("nameTransfer.successMessage", { count }), + description: + }); + + closeModal(); + } + + // Convert API errors to friendlier errors + function handleError(err: Error) { + // Construct a TranslatedError pre-keyed to nameTransfer + const tErr = (key: string) => new TranslatedError("nameTransfer." + key); + const onError = (err: Error) => notification.error({ + message: t("nameTransfer.errorNotificationTitle"), + description: translateError(t, err, "nameTransfer.errorUnknown") + }); + + switch (err.message) { + case "missing_parameter": + case "invalid_parameter": + switch ((err as APIError).parameter) { + case "name": + return onError(tErr("errorParameterNames")); + case "address": + return onError(tErr("errorParameterRecipient")); + } + break; + case "name_not_found": + return onError(tErr("errorNameNotFound")); + case "not_name_owner": + return onError(tErr("errorNotNameOwner")); + case "auth_failed": + return showAuthFailed(walletAddressMap[(err as AuthFailedError).address!]); + } + + // Pass through any other unknown errors + console.error(err); + onError(err); + } + + // Validate the form and consolidate all the data before submission async function onSubmit() { setSubmitting(true); @@ -48,7 +161,64 @@ return; } - console.log(values); + // Convert the desired names to a lookup table + const { names, recipient } = values; + const namesLUT = names + .reduce((out, name) => { + out[name] = true; + return out; + }, {} as Record); + + // Lookup the names list one last time, to associate the name owners + // to the wallets for decryption, and to show the correct confirmation + // modal. + const nameGroups = await fetchNames(t, nameSuffix, walletAddressMap); + if (!nameGroups) { + // This shouldn't happen, but if the owner suddenly has no names anymore, + // show an error. + notification.error({ + message: t("nameTransfer.errorNotifTitle"), + description: t("nameTransfer.errorNameRequired") + }); + setSubmitting(false); + return; + } + + // Filter out names owned by the recipient, just in case. + const filteredNameGroups = nameGroups + .filter(g => g.wallet.address !== recipient); + + // The names from filteredNameGroups that we actually want to transfer + const allNames = filteredNameGroups.flatMap(g => g.names); + const filteredNames = allNames.filter(n => !!namesLUT[n.key]); + + // All the names owned by the wallets, used for the confirmation modal. + const allNamesCount = allNames.length; + const count = filteredNames.length; + + // If sending multiple names, prompt for confirmation + if (count > 1) { + confirmModal.confirm({ + title: t("nameTransfer.modalTitle"), + content: , + + okText: t("nameTransfer.buttonSubmit"), + onOk: () => handleSubmit(filteredNames, recipient) + .catch(handleError) + .finally(() => setSubmitting(false)), + + cancelText: t("dialog.cancel"), + onCancel: () => setSubmitting(false) + }); + } else { + handleSubmit(filteredNames, recipient) + .catch(handleError) + .finally(() => setSubmitting(false)); + } } function onValuesChange(_: unknown, values: Partial) { @@ -64,7 +234,7 @@ setSubmitting(false); } - return ; + + return <> + {modal} + + {/* Give the modals somewhere to find the context from. This is done + * outside of the modal so that they don't get immediately destroyed when + * the modal closes. */} + {contextHolder} + {authFailedContextHolder} + {notifContextHolder} + ; +} + +interface CountRecipient { + count: number; + recipient: string; +} + +function ConfirmModalContent({ + count, + recipient, + allNamesCount +}: CountRecipient & { allNamesCount: number }): JSX.Element { + const { t } = useTranslation(); + + // Show the appropriate message, if this is all the owner's names + return = allNamesCount + ? "nameTransfer.warningAllNames" + : "nameTransfer.warningMultipleNames"} + count={count} + > + Are you sure you want to transfer {{ count }} names to + ? + ; +} + +function SuccessNotifContent({ + count, + recipient +}: CountRecipient): JSX.Element { + const { t } = useTranslation(); + + // Show the appropriate message, if this is all the owner's names + return + Transferred {{ count }} names to + . + ; } diff --git a/src/pages/names/mgmt/lookupNames.ts b/src/pages/names/mgmt/lookupNames.ts new file mode 100644 index 0000000..cb5e5ee --- /dev/null +++ b/src/pages/names/mgmt/lookupNames.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { notification } from "antd"; + +import { TFunction } from "react-i18next"; + +import { Wallet, WalletAddressMap } from "@wallets"; +import { KristName } from "@api/types"; +import { lookupNames } from "@api/lookup"; + +import { groupBy } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("kristweb:name-picker"); + +export interface NameOptionGroup { + key: string; + label: string; + wallet: Wallet; + names: NameOption[]; +} + +export interface NameOption { + key: string; + value: string; + name: string; + owner: string; +} + +export function getNameOptions( + nameSuffix: string, + wallet: Wallet, + names: KristName[] +): NameOptionGroup { + // Group by owning wallet + return { + key: wallet.address, + label: wallet.label || wallet.address, + wallet, + + // Each individual name + names: names.map(name => ({ + key: name.name, + value: name.name, + name: name.name + "." + nameSuffix, + owner: name.owner + })) + }; +} + +export async function fetchNames( + t: TFunction, + nameSuffix: string, + wallets: WalletAddressMap +): Promise { + debug("performing name fetch"); + + try { + // Get the full list of names for the given wallets. + const addresses = Object.keys(wallets); + const { names, total } = await lookupNames(addresses, { + orderBy: "name", order: "ASC", + limit: 1000 // TODO: support more than 1000 + }); + + // Since more than 1000 isn't supported yet, show a warning + if (total > 1000) + notification.warning({ message: t("namePicker.warningTotalLimit") }); + + // Group the names into OptGroups per wallet. + const options = Object.entries(groupBy(names, n => n.owner)) + .map(([address, group]) => + getNameOptions(nameSuffix, wallets[address], group)); + + debug("got names:", names, options); + return options; + } catch (err) { + notification.error({ + message: t("error"), + description: t("namePicker.errorLookup") + }); + return null; + } +}