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/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 + /> + + +