diff --git a/public/locales/en.json b/public/locales/en.json index 7088f68..207f790 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -45,6 +45,7 @@ "sort": "Sort results", "settings": "Settings", + "staking": "Staking", "more": "More" }, @@ -70,6 +71,29 @@ "updateReload": "Reload" }, + "staking": { + "modalSubmit": "Submit", + "buttonSubmit": "Submit", + "deposit": "Deposit", + "withdraw": "Withdraw", + "errorInvalidQuery": "The query parameters were invalid, they were ignored.", + "errorUnknown": "Unknown error sending transaction. See console for details.", + "errorNotificationTitle": "Transaction failed", + "errorParameterAmount": "Invalid amount.", + "errorInsufficientFunds": "Insufficient funds in wallet or stake.", + "labelAction": "Action", + "labelWallet": "Staking wallet", + "stakeLargeConfirm": "Are you sure you want to stake <1 />?", + "stakeLargeConfirmHalf": "Are you sure you want to stake <1 />? This is over half your balance!", + "stakeLargeConfirmAll": "Are you sure you want to stake <1 />? This is your entire balance!", + "successNotificationTitle": "Transaction successful", + "successNotificationContent": "Your new stake balance is <1 /> after a staking action with <3 />.", + "successNotificationButton": "View transactions", + "modalTitle": "Staking", + "siteTitle": "Staking", + "title": "Staking" + }, + "dialog": { "close": "Close", "yes": "Yes", diff --git a/src/components/results/NoWalletsResult.tsx b/src/components/results/NoWalletsResult.tsx index 5eeac1d..124b831 100644 --- a/src/components/results/NoWalletsResult.tsx +++ b/src/components/results/NoWalletsResult.tsx @@ -11,7 +11,7 @@ import { SmallResult } from "./SmallResult"; -export type ResultType = "transactions" | "names" | "sendTransaction"; +export type ResultType = "transactions" | "names" | "sendTransaction" | "staking"; interface Props { type?: ResultType; diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 3ac3ffc..eafee8e 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -10,6 +10,7 @@ import { SendTransactionPage } from "@pages/transactions/send/SendTransactionPage"; import { RequestPage } from "@pages/transactions/request/RequestPage"; +import { StakingPage } from "@pages/staking/StakingPage"; import { AddressPage } from "@pages/addresses/AddressPage"; import { BlocksPage } from "@pages/blocks/BlocksPage"; @@ -46,9 +47,10 @@ { path: "/me/names", name: "myNames", component: }, - // Payments + // Payments & Staking { path: "/send", name: "sendTransaction", component: }, { path: "/request", name: "request", component: }, + { path: "/staking", name: "staking", component: }, // Network explorer { path: "/network/addresses/:address", name: "address", component: }, diff --git a/src/layout/nav/AppHeader.tsx b/src/layout/nav/AppHeader.tsx index 2649f8d..41b2660 100644 --- a/src/layout/nav/AppHeader.tsx +++ b/src/layout/nav/AppHeader.tsx @@ -2,7 +2,7 @@ // This file is part of TenebraWeb 2 under AGPL-3.0. // Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt import { Layout, Menu, Grid } from "antd"; -import { SendOutlined, DownloadOutlined, MenuOutlined } from "@ant-design/icons"; +import { SendOutlined, DownloadOutlined, MenuOutlined, BankOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; @@ -55,6 +55,13 @@ {t("nav.request")} + + {/* Staking Tenebra */} + }> + + {t("nav.staking")} + + } {/* Spacer to push search box to the right */} diff --git a/src/pages/staking/QueryParamsHook.tsx b/src/pages/staking/QueryParamsHook.tsx new file mode 100644 index 0000000..64d8a13 --- /dev/null +++ b/src/pages/staking/QueryParamsHook.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useMemo } from "react"; + +import { useTFns } from "@utils/i18n"; + +import { useLocation } from "react-router-dom"; +import { message } from "antd"; + +interface Res { + amount?: number; +} + +export function useStakingQuery(): Res { + const { tStr } = useTFns("staking."); + const search = useLocation().search; + // Memoise the query parsing, as notifications are triggered directly here. To + // avoid spamming them, this should only run once per query string. + return useMemo(() => { + const query = new URLSearchParams(search); + + // Fetch the form parameters from the query string + const rawAmount = query.get("amount")?.trim(); + + const parsedAmount = rawAmount ? parseInt(rawAmount) : undefined; + const amountValid = rawAmount && !isNaN(parsedAmount!) && parsedAmount! > 0; + + // Show a notification if any parameter is invalid + if (rawAmount && !amountValid) { + message.error(tStr("errorInvalidQuery")); + return {}; + } + + // The parameters were valid (or non-existent), return them + return { + amount: parsedAmount, + }; + }, [tStr, search]); +} diff --git a/src/pages/staking/StakingConfirmModal.tsx b/src/pages/staking/StakingConfirmModal.tsx new file mode 100644 index 0000000..9d286a6 --- /dev/null +++ b/src/pages/staking/StakingConfirmModal.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC, Attributes } from "react"; + +import { Trans } from "react-i18next"; +import { useTFns } from "@utils/i18n"; + +import { TenebraValue } from "@comp/tenebra/TenebraValue"; + +interface StakingConfirmModalContentsProps { + amount: number; + balance: number; +} + +export const StakingConfirmModalContents: FC = ({ amount, balance, key2 }) => { + const { t, tKey } = useTFns("staking."); + + // Show the appropriate message, if this is just over half the + // balance, or if it is the entire balance. + return = balance + ? "stakingLargeConfirmAll" + : "stakingLargeConfirmHalf"))} + > + Are you sure you want to send ? + This is over half your balance! + ; +}; diff --git a/src/pages/staking/StakingForm.tsx b/src/pages/staking/StakingForm.tsx new file mode 100644 index 0000000..994236d --- /dev/null +++ b/src/pages/staking/StakingForm.tsx @@ -0,0 +1,366 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useRef, useMemo, useEffect } from "react"; +import { Row, Col, Form, FormInstance, Modal } from "antd"; +import Select, { RefSelectProps } from "antd/lib/select"; + +import { useTranslation } from "react-i18next"; +import { TranslatedError } from "@utils/i18n"; + +import { useSelector, useDispatch } from "react-redux"; +import { store } from "@app"; +import { RootState } from "@store"; +import { setLastTxFrom } from "@actions/WalletsActions"; + +import { useWallets, Wallet } from "@wallets"; +import { useMountEffect } from "@utils/hooks"; +import { sha256 } from "@utils/crypto"; +import { useBooleanSetting, useIntegerSetting } from "@utils/settings"; + +import { useSyncNode } from "@api"; +import { TenebraStake } from "@api/types"; +import { makeDepositTransaction, makeWithdrawTransaction } from "@api/transactions"; +import { handleStakingError } from "./handleErrors"; +import { useAuthFailedModal } from "@api/AuthFailed"; + +import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; +import { AmountInput } from "@comp/transactions/AmountInput"; +import { StakingConfirmModalContents } from "./StakingConfirmModal"; + +import awaitTo from "await-to-js"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:send-transaction-form"); + +// This is from https://github.com/tmpim/Tenebra/blob/a924f3f/src/controllers/transactions.js#L102 +// except `+` is changed to `*`. +export const METADATA_REGEXP = /^[\x20-\x7F\n]*$/i; + +export type StakingActionType = "deposit" | "withdraw"; + +export interface FormValues { + from: string; + action: StakingActionType; + amount: number; +} + +interface Props { + from?: Wallet | string; + amount?: number; + form: FormInstance; + triggerSubmit: () => Promise; +} + +function StakingForm({ + from: rawInitialFrom, + amount: initialAmount, + form, + triggerSubmit +}: Props): JSX.Element { + const { t } = useTranslation(); + + // Get the initial wallet to show for the 'from' field. Use the provided + // wallet if we were given one, otherwise use the saved 'last wallet', + // or the first wallet we can find. + const initialFromAddress = typeof rawInitialFrom === "string" + ? rawInitialFrom : rawInitialFrom?.address; + + const { addressList, walletAddressMap } = useWallets(); + const firstWallet = addressList[0]; + + // Validate the lastTxFrom wallet still exists + const dispatch = useDispatch(); + const lastTxFrom = useSelector((s: RootState) => s.wallets.lastTxFrom); + const lastTxFromAddress = lastTxFrom && addressList.includes(lastTxFrom) + ? lastTxFrom : undefined; + + const initialFrom = initialFromAddress || lastTxFromAddress || firstWallet; + + const [from, setFrom] = useState(initialFrom); + + // Focus the 'to' input on initial render + const toRef = useRef(null); + useMountEffect(() => { + toRef?.current?.focus(); + }); + + function onValuesChange( + changed: Partial, + values: Partial + ) { + setFrom(values.from || ""); + + // Update and save the lastTxFrom so the next time the modal is opened + // it will remain on this address + if (changed.from) { + const currentWallet = walletAddressMap[changed.from]; + if (currentWallet && currentWallet.address !== lastTxFromAddress) { + debug("updating lastTxFrom to %s", currentWallet.address); + dispatch(setLastTxFrom(currentWallet)); + localStorage.setItem("lastTxFrom", currentWallet.address); + } + } + } + + const initialValues: FormValues = useMemo(() => ({ + from: initialFrom, + amount: initialAmount || 1, + action: "deposit" + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [ + rawInitialFrom, + initialFrom, + initialAmount + ]); + + // If the initial values change, refresh the form + useEffect(() => { + form?.setFieldsValue(initialValues); + }, [form, initialValues]); + + return
+ + {/* From */} + + + + + {/* Mode */} + + + + + + + + {/* Amount */} + form.setFieldsValue({ amount })} + tabIndex={3} + /> + ; +} + +interface StakingFormHookProps { + from?: Wallet | string; + amount?: number; + onError?: (err: Error) => void; + onSuccess?: (stake: TenebraStake) => void; + allowClearOnSend?: boolean; +} + +interface StakingFormHookResponse { + form: FormInstance; + triggerSubmit: () => Promise; + triggerReset: () => void; + isSubmitting: boolean; + stakingForm: JSX.Element; +} + +export function useStakingForm({ + from: initialFrom, + amount: initialAmount, + onError, + onSuccess, + allowClearOnSend +}: StakingFormHookProps = {}): StakingFormHookResponse { + const { t } = useTranslation(); + + const [form] = Form.useForm(); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Used to check for warning on large transactions + const { walletAddressMap } = useWallets(); + const url = useSyncNode(); + + // Confirmation modal used for when the transaction amount is very large. + // 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(); + + // If the form allows it, and the setting is enabled, clear the form when + // sending a transaction. + const confirmOnSend = useBooleanSetting("confirmTransactions"); + const clearOnSend = useBooleanSetting("clearTransactionForm"); + const sendDelay = useIntegerSetting("sendTransactionDelay"); + + // Called when the modal is closed + function onReset() { + form.resetFields(); + setIsSubmitting(false); + } + + // Take the form values and known wallet and submit the transaction + async function submitStakingTransaction( + { amount, action }: FormValues, + wallet: Wallet + ): Promise { + // Manually get the master password from the store state, because this might + // get called immediately after an auth, which doesn't give the hook time to + // update this submitTransaction function. The password here is used to + // decrypt the wallet to make the transaction. + const masterPassword = store.getState().masterPassword.masterPassword; + if (!masterPassword) + throw new TranslatedError("sendTransaction.errorWalletDecrypt"); + + // API errors will be bubbled up to the caller + if (action === "deposit") { + const stake = await makeDepositTransaction( + masterPassword, + wallet, + amount, + ); + + // Intentionally delay transaction submission, to prevent accidental double + // clicks on fast networks. + if (sendDelay > 0) + await (() => new Promise(resolve => setTimeout(resolve, sendDelay)))(); + + // Clear the form if the setting for it is enabled + if (allowClearOnSend && clearOnSend) + form.resetFields(); + + onSuccess?.(stake); + } else { + const stake = await makeWithdrawTransaction( + masterPassword, + wallet, + amount, + ); + + // Intentionally delay transaction submission, to prevent accidental double + // clicks on fast networks. + if (sendDelay > 0) + await (() => new Promise(resolve => setTimeout(resolve, sendDelay)))(); + + // Clear the form if the setting for it is enabled + if (allowClearOnSend && clearOnSend) + form.resetFields(); + + onSuccess?.(stake); + } + + } + + // Convert API errors to friendlier errors + const handleError = handleStakingError.bind(handleStakingError, + onError, showAuthFailed); + + async function onSubmit() { + setIsSubmitting(true); + + // Get the form values + const [err, values] = await awaitTo(form.validateFields()); + if (err || !values) { + // Validation errors are handled by the form + setIsSubmitting(false); + return; + } + + // Find the wallet we're trying to pay from, and verify it actually exists + // and has a balance (shouldn't happen) + const [err2, currentWallet] = await awaitTo((async () => { + const currentWallet = walletAddressMap[values.from]; + if (!currentWallet) + throw new TranslatedError("sendTransaction.errorWalletGone"); + if (!currentWallet.balance) + throw new TranslatedError("sendTransaction.errorAmountTooHigh"); + + return currentWallet; + })()); + + // Push out any errors with the wallet + if (err2 || !currentWallet?.balance) { + onError?.(err2!); + setIsSubmitting(false); + return; + } + + // If the transaction is large (over half the balance), prompt for + // confirmation before sending + const { amount } = values; + // TODO: Anti-midiocy here but I'm too lazy to figure out what it does + const confirmable = await sha256(url) !== "cadc9145658308ead9ade59730063772f9a4d682650842981d3c075c5240cfee"; + const showConfirm = confirmOnSend || confirmable; + const isLarge = amount >= currentWallet.balance / 2; + if (showConfirm || isLarge) { + // It's large, prompt for confirmation + confirmModal.confirm({ + title: t("staking.modalTitle"), + content: , + + // Transaction looks OK, submit it + okText: t("staking.buttonSubmit"), + onOk: () => submitStakingTransaction(values, currentWallet) + .catch(err => handleError(err, currentWallet)) + .finally(() => setIsSubmitting(false)), + + cancelText: t("dialog.cancel"), + onCancel: () => setIsSubmitting(false) + }); + } else { + // Transaction looks OK, submit it + submitStakingTransaction(values, currentWallet) + .catch(err => handleError(err, currentWallet)) + .finally(() => setIsSubmitting(false)); + } + } + + // Create the transaction form instance here to be rendered by the caller + const stakingForm = <> + + + {/* Give the modals somewhere to find the context from. */} + {contextHolder} + {authFailedContextHolder} + ; + + return { + form, + triggerSubmit: onSubmit, + triggerReset: onReset, + isSubmitting, + stakingForm + }; +} diff --git a/src/pages/staking/StakingPage.less b/src/pages/staking/StakingPage.less new file mode 100644 index 0000000..e8eae64 --- /dev/null +++ b/src/pages/staking/StakingPage.less @@ -0,0 +1,29 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../../App.less"; + +.staking-page { + .staking-container { + //display: flex; + flex-direction: column; + + margin: 0 auto; + width: 100%; + max-width: 768px; + + background: @kw-light; + border-radius: @kw-big-card-border-radius; + + padding: @padding-lg; + + .staking-submit { + float: right; + } + } + + .staking-alert { + max-width: 768px; + margin: 0 auto @margin-md auto; + } +} diff --git a/src/pages/staking/StakingPage.tsx b/src/pages/staking/StakingPage.tsx new file mode 100644 index 0000000..487a654 --- /dev/null +++ b/src/pages/staking/StakingPage.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState } from "react"; +import { Button, Alert } from "antd"; +import { SendOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; +import { translateError } from "@utils/i18n"; + +import { PageLayout } from "@layout/PageLayout"; + +import { useWallets } from "@wallets"; +import { NoWalletsResult } from "@comp/results/NoWalletsResult"; +import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; + +import { TenebraStake } from "@api/types"; + +import { useStakingForm } from "./StakingForm"; +import { NotifSuccessContents, NotifSuccessButton } from "./Success"; +import { useStakingQuery } from "./QueryParamsHook"; + +import "./StakingPage.less"; + +export function StakingPage(): JSX.Element { + const { t } = useTranslation(); + + // The success or error alert + const [alert, setAlert] = useState(null); + + // Get any pre-filled values from the query parameters + const { amount } = useStakingQuery(); + + // Create the transaction form + const { isSubmitting, triggerSubmit, stakingForm } = useStakingForm({ + amount, + onSuccess: stake => setAlert(), + onError: err => setAlert() + }); + + // Don't show the form if there are no wallets. + const { addressList } = useWallets(); + const hasWallets = addressList?.length > 0; + + function onSubmit() { + // Close the alert before submission, to forcibly move the form + setAlert(null); + triggerSubmit(); + } + + return + {hasWallets + ? <> + {/* Show the success/error alert if available */} + {alert} + +
+ {stakingForm} + + {/* Send submit button */} + + + + + {/* Clearfix for submit button floated right */} +
+
+ + : } + ; +} + +function AlertSuccess({ stake }: { stake: TenebraStake }): JSX.Element { + const { t } = useTranslation(); + + return } + action={} + />; +} + +function AlertError({ err }: { err: Error }): JSX.Element { + const { t } = useTranslation(); + + return ; +} diff --git a/src/pages/staking/Success.tsx b/src/pages/staking/Success.tsx new file mode 100644 index 0000000..a6f7a17 --- /dev/null +++ b/src/pages/staking/Success.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Button } from "antd"; + +import { useTranslation, Trans } from "react-i18next"; + +import { Link } from "react-router-dom"; + +import { TenebraStake } from "@api/types"; +import { TenebraValue } from "@comp/tenebra/TenebraValue"; +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; + +export function NotifSuccessContents({ stake }: { stake: TenebraStake }): JSX.Element { + const { t } = useTranslation(); + + return + Your new stae balance + + after a staking action with + + ; +} + +export function NotifSuccessButton({ stake }: { stake: TenebraStake }): JSX.Element { + const { t } = useTranslation(); + + return + + ; +} diff --git a/src/pages/staking/handleErrors.ts b/src/pages/staking/handleErrors.ts new file mode 100644 index 0000000..8488ffc --- /dev/null +++ b/src/pages/staking/handleErrors.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { TranslatedError } from "@utils/i18n"; + +import { APIError } from "@api"; +import { ShowAuthFailedFn } from "@api/AuthFailed"; + +import { Wallet } from "@wallets"; + +export function handleStakingError( + onError: ((error: Error) => void) | undefined, + showAuthFailed: ShowAuthFailedFn, + err: Error, + from?: Wallet +): void { + // Construct a TranslatedError pre-keyed to sendTransaction + const tErr = (key: string) => new TranslatedError("staking." + key); + + switch (err.message) { + case "missing_parameter": + case "invalid_parameter": + switch ((err as APIError).parameter) { + case "privatekey": + return onError?.(tErr("errorParameterPrivatekey")); + case "amount": + return onError?.(tErr("errorParameterAmount")); + } + break; + case "insufficient_funds": + return onError?.(tErr("errorInsufficientFunds")); + case "auth_failed": + return showAuthFailed(from!); + } + + // Pass through any other unknown errors + onError?.(err); +} diff --git a/src/tenebra/api/transactions.ts b/src/tenebra/api/transactions.ts index 984cb82..c3d9133 100644 --- a/src/tenebra/api/transactions.ts +++ b/src/tenebra/api/transactions.ts @@ -3,7 +3,7 @@ // Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt import { TranslatedError } from "@utils/i18n"; -import { TenebraTransaction } from "./types"; +import { TenebraStake, TenebraTransaction } from "./types"; import * as api from "."; import { Wallet, decryptWallet } from "@wallets"; @@ -12,6 +12,10 @@ transaction: TenebraTransaction; } +interface StakingActionResponse { + stake: TenebraStake; +} + export async function makeTransaction( masterPassword: string, from: Wallet, @@ -35,3 +39,46 @@ return transaction; } + +export async function makeDepositTransaction( + masterPassword: string, + from: Wallet, + amount: number, +): Promise { + // Attempt to decrypt the wallet to get the privatekey + const decrypted = await decryptWallet(masterPassword, from); + if (!decrypted) + throw new TranslatedError("sendTransaction.errorWalletDecrypt"); + const { privatekey } = decrypted; + + const { stake } = await api.post( + "/staking", + { + privatekey, amount + } + ); + + return stake; +} + + +export async function makeWithdrawTransaction( + masterPassword: string, + from: Wallet, + amount: number, +): Promise { + // Attempt to decrypt the wallet to get the privatekey + const decrypted = await decryptWallet(masterPassword, from); + if (!decrypted) + throw new TranslatedError("sendTransaction.errorWalletDecrypt"); + const { privatekey } = decrypted; + + const { stake } = await api.post( + "/staking/withdraw", + { + privatekey, amount + } + ); + + return stake; +} diff --git a/src/tenebra/api/types.ts b/src/tenebra/api/types.ts index ba905ba..3fb2ad3 100644 --- a/src/tenebra/api/types.ts +++ b/src/tenebra/api/types.ts @@ -26,6 +26,12 @@ type: TenebraTransactionType; } +export interface TenebraStake { + owner: string; + stake: number; + active: boolean; +} + export interface TenebraBlock { height: number; address: string;