From cdf1ccb77e0065704f278b56d09d78e81aa50e5f Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Sat, 24 Sep 2022 00:34:05 +0200 Subject: [PATCH 1/2] Add a settings dialog, with "dangerous mode" setting Also, in modals, improve handling of danger, and add delays. --- src/app/components/Modal/index.tsx | 80 ++++++++++++++++--- src/app/components/SettingsButton/index.tsx | 22 +++++ src/app/components/SettingsDialog/index.tsx | 80 +++++++++++++++++++ .../components/SettingsDialog/slice/index.ts | 22 +++++ .../SettingsDialog/slice/selectors.ts | 8 ++ .../components/SettingsDialog/slice/types.ts | 3 + .../__snapshots__/index.test.tsx.snap | 33 ++++++++ src/app/components/Sidebar/index.tsx | 2 + src/locales/en/translation.json | 15 ++++ src/store/reducers.ts | 2 + src/types/RootState.ts | 2 + 11 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 src/app/components/SettingsButton/index.tsx create mode 100644 src/app/components/SettingsDialog/index.tsx create mode 100644 src/app/components/SettingsDialog/slice/index.ts create mode 100644 src/app/components/SettingsDialog/slice/selectors.ts create mode 100644 src/app/components/SettingsDialog/slice/types.ts diff --git a/src/app/components/Modal/index.tsx b/src/app/components/Modal/index.tsx index baa01a8fdc..af23b6c02d 100644 --- a/src/app/components/Modal/index.tsx +++ b/src/app/components/Modal/index.tsx @@ -1,13 +1,30 @@ -import { createContext, useCallback, useContext, useState } from 'react' +import React from 'react' +import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { Box, Button, Layer, Heading, Paragraph } from 'grommet' import { useTranslation } from 'react-i18next' import { Alert, Checkmark, Close } from 'grommet-icons/icons' +import { AlertBox } from '../AlertBox' +import { selectAllowDangerousSetting } from '../SettingsDialog/slice/selectors' +import { useSelector } from 'react-redux' interface Modal { title: string description: string handleConfirm: () => void + + /** + * Is this a dangerous operation? + * + * If marked as such, it will only be possible to execute it if the wallet is configured to run in dangerous mode. + * + * It also automatically implies a mandatory waiting time of 10 sec, unless specified otherwise. + */ isDangerous: boolean + + /** + * How long does the user have to wait before he can actually confirm the action? + */ + mustWaitSecs?: number } interface ModalContainerProps { @@ -32,12 +49,55 @@ const ModalContainer = ({ modal, closeModal }: ModalContainerProps) => { modal.handleConfirm() closeModal() }, [closeModal, modal]) + const { isDangerous, mustWaitSecs } = modal + const allowDangerous = useSelector(selectAllowDangerousSetting) + const forbidden = isDangerous && !allowDangerous + const waitingTime = forbidden + ? 0 // If the action is forbidden, there is nothing to wait for + : isDangerous + ? mustWaitSecs ?? 10 // For dangerous actions, we require 10 seconds of waiting, unless specified otherwise. + : mustWaitSecs ?? 0 // For normal, non-dangerous operations, just use what was specified + + const [secsLeft, setSecsLeft] = useState(0) + + useEffect(() => { + if (waitingTime) { + setSecsLeft(waitingTime) + const stopCounting = () => window.clearInterval(interval) + const interval = window.setInterval( + () => + setSecsLeft(seconds => { + const remains = seconds - 1 + if (!remains) stopCounting() + return remains + }), + 1000, + ) + return stopCounting + } + }, [waitingTime]) return ( {modal.title} {modal.description} + {forbidden && ( + + {t( + 'dangerMode.youDontWantThis', + "You most probably don't want to do this, so I won't allow it. If you really do, then please enable the 'dangerous mode' in wallet settings, and try again.", + )} + + )} + {isDangerous && allowDangerous && ( + + {t( + 'dangerMode.youCanButDoYouWant', + "You most probably shouldn't do this, but since you have specifically enabled 'dangerous mode' in wallet settings, we won't stop you.", + )} + + )} +
{ const size = useContext(ResponsiveContext) @@ -211,6 +212,7 @@ const SidebarFooter = (props: SidebarFooterProps) => { + } label="GitHub" diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c7af45d1d7..59dc156b31 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -119,6 +119,13 @@ "newMnemonic": "Generate a new mnemonic", "thisIsYourPhrase": "This is your mnemonic" }, + "dangerMode": { + "description": "Dangerous mode: should the wallet let the user shoot himself in the foot?", + "off": "Off - Refuse to execute nonsensical actions", + "on": "On - Allow executing nonsensical actions. Don't blame Oasis!", + "youCanButDoYouWant": "You most probably shouldn't do this, but since you have specifically enabled 'dangerous mode' in wallet settings, we won't stop you.", + "youDontWantThis": "You most probably don't want to do this, so I won't allow it. If you really do, then please enable the 'dangerous mode' in wallet settings, and try again." + }, "delegations": { "activeDelegations": "Active delegations", "debondingDelegations": "Debonding delegations", @@ -193,6 +200,7 @@ "menu": { "closeWallet": "Close wallet", "home": "Home", + "settings": "Settings", "stake": "Stake", "wallet": "Wallet" }, @@ -229,6 +237,13 @@ "showPrivateKey": "Show private key" } }, + "settings": { + "dialog": { + "close": "Close", + "description": "This is where you can configure the behavior of the Oasis Wallet.", + "title": "Wallet settings" + } + }, "theme": { "darkMode": "Dark mode", "lightMode": "Light mode" diff --git a/src/store/reducers.ts b/src/store/reducers.ts index eb5435e3cb..9111fd363f 100644 --- a/src/store/reducers.ts +++ b/src/store/reducers.ts @@ -12,6 +12,7 @@ import stakingReducer from 'app/state/staking' import transactionReducer from 'app/state/transaction' import walletReducer from 'app/state/wallet' import themeReducer from 'styles/theme/slice' +import settingReducer from 'app/components/SettingsDialog/slice' export function createReducer() { const rootReducer = combineReducers({ @@ -24,6 +25,7 @@ export function createReducer() { theme: themeReducer, transaction: transactionReducer, wallet: walletReducer, + settings: settingReducer, }) return rootReducer diff --git a/src/types/RootState.ts b/src/types/RootState.ts index f3e356a3f7..7c554adcb4 100644 --- a/src/types/RootState.ts +++ b/src/types/RootState.ts @@ -8,6 +8,7 @@ import { TransactionState } from 'app/state/transaction/types' import { ImportAccountsState } from 'app/state/importaccounts/types' import { StakingState } from 'app/state/staking/types' import { FatalErrorState } from 'app/state/fatalerror/types' +import { SettingsState } from '../app/components/SettingsDialog/slice/types' // [IMPORT NEW CONTAINERSTATE ABOVE] < Needed for generating containers seamlessly export interface RootState { @@ -20,6 +21,7 @@ export interface RootState { importAccounts: ImportAccountsState staking: StakingState fatalError: FatalErrorState + settings: SettingsState // [INSERT NEW REDUCER KEY ABOVE] < Needed for generating containers seamlessly } From 1809a2f1f271cd29747585016bf24cd93d7e6d51 Mon Sep 17 00:00:00 2001 From: csillag Date: Thu, 22 Sep 2022 19:16:37 +0200 Subject: [PATCH 2/2] Implement token burning (One test case needs to be fixed.) --- src/app/components/Transaction/index.tsx | 35 ++++++-- .../TransactionTypeFormatter/index.tsx | 1 + src/app/lib/transaction.ts | 19 +++++ .../Features/AccountTokenBurning/index.tsx | 30 +++++++ .../SendBurn/__tests__/index.test.tsx | 38 +++++++++ .../AccountPage/Features/SendBurn/index.tsx | 83 +++++++++++++++++++ .../__snapshots__/index.test.tsx.snap | 12 ++- src/app/pages/AccountPage/index.tsx | 7 ++ src/app/state/account/saga.ts | 20 ++++- src/app/state/transaction/index.ts | 6 ++ src/app/state/transaction/saga.ts | 14 ++++ src/app/state/transaction/types.ts | 12 ++- src/locales/en/translation.json | 15 ++++ src/vendors/explorer/models/OperationsRow.ts | 41 ++++----- src/vendors/monitor.ts | 1 + src/vendors/oasisscan.ts | 1 + src/vendors/oasisscan/models/OperationsRow.ts | 33 ++++---- 17 files changed, 318 insertions(+), 50 deletions(-) create mode 100644 src/app/pages/AccountPage/Features/AccountTokenBurning/index.tsx create mode 100644 src/app/pages/AccountPage/Features/SendBurn/__tests__/index.test.tsx create mode 100644 src/app/pages/AccountPage/Features/SendBurn/index.tsx diff --git a/src/app/components/Transaction/index.tsx b/src/app/components/Transaction/index.tsx index d46b8b2140..4f8590af5a 100644 --- a/src/app/components/Transaction/index.tsx +++ b/src/app/components/Transaction/index.tsx @@ -18,6 +18,7 @@ import { LinkNext, Atm, Alert, + Trash, } from 'grommet-icons/icons' import type { Icon } from 'grommet-icons/icons' import * as React from 'react' @@ -99,6 +100,19 @@ export function Transaction(props: TransactionProps) { ), } + const burnTransaction: TransactionDictionary[transactionTypes.TransactionType][TransactionSide] = { + destination: '', + icon: Trash, + header: () => ( + + ), + } + const unrecognizedTransaction: TransactionDictionary[transactionTypes.TransactionType][TransactionSide] = { destination: t('account.transaction.unrecognizedTransaction.destination', 'Other address'), icon: Alert, @@ -286,6 +300,10 @@ export function Transaction(props: TransactionProps) { [TransactionSide.Received]: genericTransaction, [TransactionSide.Sent]: genericTransaction, }, + [transactionTypes.TransactionType.StakingBurn]: { + [TransactionSide.Received]: burnTransaction, + [TransactionSide.Sent]: burnTransaction, + }, [transactionTypes.TransactionType.RoothashExecutorCommit]: { [TransactionSide.Received]: genericTransaction, [TransactionSide.Sent]: genericTransaction, @@ -349,6 +367,7 @@ export function Transaction(props: TransactionProps) { const Icon = matchingConfiguration.icon const header = matchingConfiguration.header() + const hasDestination = transaction.type !== transactionTypes.TransactionType.StakingBurn const destination = matchingConfiguration.destination const backendLinks = config[props.network][backend()] const externalExplorerLink = transaction.runtimeId @@ -392,13 +411,15 @@ export function Transaction(props: TransactionProps) { {!isMobile && ( - + {hasDestination && ( + + )} { addEscrow: t('transaction.types.addEscrow', 'Delegating your tokens to a validator and generate rewards'), reclaimEscrow: t('transaction.types.reclaimEscrow', 'Reclaiming your tokens delegated to a validator'), transfer: t('transaction.types.transfer', 'Transferring tokens from your account to another'), + burn: t('transaction.types.burn', 'Burn tokens in your account'), } const typeMessage = typeMap[type] diff --git a/src/app/lib/transaction.ts b/src/app/lib/transaction.ts index 02252f36a9..53b2f07303 100644 --- a/src/app/lib/transaction.ts +++ b/src/app/lib/transaction.ts @@ -79,6 +79,25 @@ export class OasisTransaction { return tw } + public static async buildBurn( + nic: OasisClient, + signer: Signer, + amount: bigint, + ): Promise> { + const tw = oasis.staking.burnWrapper() + const nonce = await OasisTransaction.getNonce(nic, signer) + tw.setNonce(nonce) + tw.setFeeAmount(oasis.quantity.fromBigInt(0n)) + tw.setBody({ + amount: oasis.quantity.fromBigInt(amount), + }) + + const gas = await tw.estimateGas(nic, signer.public()) + tw.setFeeGas(gas) + + return tw + } + public static async signUsingLedger( chainContext: string, signer: ContextSigner, diff --git a/src/app/pages/AccountPage/Features/AccountTokenBurning/index.tsx b/src/app/pages/AccountPage/Features/AccountTokenBurning/index.tsx new file mode 100644 index 0000000000..7b50ecf6f6 --- /dev/null +++ b/src/app/pages/AccountPage/Features/AccountTokenBurning/index.tsx @@ -0,0 +1,30 @@ +/** + * + * AccountDetails + * + */ +import { Box } from 'grommet' +import React, { memo } from 'react' +import { useSelector } from 'react-redux' +import { TransactionHistory } from '../TransactionHistory' +import { selectIsAddressInWallet } from 'app/state/selectIsAddressInWallet' +import { SendBurn } from '../SendBurn' + +interface Props {} + +export const AccountTokenBurning = memo((props: Props) => { + const isAddressInWallet = useSelector(selectIsAddressInWallet) + + return ( + + {isAddressInWallet && ( + + + + )} + + + + + ) +}) diff --git a/src/app/pages/AccountPage/Features/SendBurn/__tests__/index.test.tsx b/src/app/pages/AccountPage/Features/SendBurn/__tests__/index.test.tsx new file mode 100644 index 0000000000..bf1c2f3c23 --- /dev/null +++ b/src/app/pages/AccountPage/Features/SendBurn/__tests__/index.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { transactionActions } from 'app/state/transaction' +import * as React from 'react' +import { Provider } from 'react-redux' +import { configureAppStore } from 'store/configureStore' +import { SendBurn } from '..' + +const renderComponent = (store: any) => + render( + + + , + ) + +describe('', () => { + let store: ReturnType + + beforeEach(() => { + store = configureAppStore() + }) + + it('should dispatch sendBurn action on submit', () => { + const spy = jest.spyOn(store, 'dispatch') + renderComponent(store) + + userEvent.type(screen.getByPlaceholderText('0'), '10') + userEvent.click(screen.getByRole('button')) + + expect(spy).toHaveBeenCalledWith({ + payload: { + amount: '10000000000', + type: 'burn', + }, + type: 'transaction/sendBurn', + } as ReturnType) + }) +}) diff --git a/src/app/pages/AccountPage/Features/SendBurn/index.tsx b/src/app/pages/AccountPage/Features/SendBurn/index.tsx new file mode 100644 index 0000000000..bdc166e6a9 --- /dev/null +++ b/src/app/pages/AccountPage/Features/SendBurn/index.tsx @@ -0,0 +1,83 @@ +import { TransactionStatus } from 'app/components/TransactionStatus' +import { transactionActions } from 'app/state/transaction' +import { selectTransaction } from 'app/state/transaction/selectors' +import { Box, Button, Form, FormField, TextInput } from 'grommet' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { parseRoseStringToBaseUnitString } from 'app/lib/helpers' +import { useModal } from '../../../../components/Modal' + +export interface SendBurnProps { + isAddressInWallet: boolean +} + +export function SendBurn(props: SendBurnProps) { + if (!props.isAddressInWallet) { + throw new Error('SendTransaction component should only appear on your accounts') + } + + const { t } = useTranslation() + const dispatch = useDispatch() + const { launchModal } = useModal() + const { error, success } = useSelector(selectTransaction) + const [amount, setAmount] = useState('') + const sendBurn = () => + dispatch( + transactionActions.sendBurn({ + type: 'burn', + amount: parseRoseStringToBaseUnitString(amount), + }), + ) + const onSubmit = () => + launchModal({ + title: t('account.sendBurn.confirmBurning.title', 'Are you sure you want to continue?'), + description: t( + 'account.sendBurn.confirmBurning.description', + 'You are about to burn these tokens. You are not sending them anywhere, but destroying them. They will completely cease to exist, and there is no way to get them back.', + ), + handleConfirm: sendBurn, + isDangerous: true, + }) + + // On successful transaction, clear the fields + useEffect(() => { + if (success) { + setAmount('') + } + }, [success]) + + // Cleanup effect - clear the transaction when the component unmounts + useEffect(() => { + return function cleanup() { + dispatch(transactionActions.clearTransaction()) + } + }, [dispatch]) + + return ( + +
+ + + setAmount(event.target.value)} + required + /> + + +