diff --git a/src/components/CopyBtn/index.jsx b/src/components/CopyBtn/index.jsx index 41eeae77..d31da79b 100644 --- a/src/components/CopyBtn/index.jsx +++ b/src/components/CopyBtn/index.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import Tooltip from '@material-ui/core/Tooltip' import { makeStyles } from '@material-ui/core/styles' +import cn from 'classnames' import Img from '~/components/layout/Img' import { copyToClipboard } from '~/utils/clipboard' import { xs } from '~/theme/variables' @@ -26,11 +27,12 @@ const useStyles = makeStyles({ }) type CopyBtnProps = { + className?: any, content: string, increaseZindex?: boolean, } -const CopyBtn = ({ content, increaseZindex = false }: CopyBtnProps) => { +const CopyBtn = ({ className, content, increaseZindex = false }: CopyBtnProps) => { const [clicked, setClicked] = useState(false) const classes = useStyles() const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {} @@ -50,7 +52,7 @@ const CopyBtn = ({ content, increaseZindex = false }: CopyBtnProps) => { }} classes={customClasses} > -
+
{ +const EtherscanBtn = ({ + type, value, className, increaseZindex = false, +}: EtherscanBtnProps) => { const classes = useStyles() const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {} @@ -39,7 +43,7 @@ const EtherscanBtn = ({ type, value, increaseZindex = false }: EtherscanBtnProps ( {cut ? shortVersionOf(value, cut) : value} - - + + {knownAddress !== undefined ? : null} ) diff --git a/src/components/EtherscanLink/style.js b/src/components/EtherscanLink/style.js index eadaa727..083a9c4a 100644 --- a/src/components/EtherscanLink/style.js +++ b/src/components/EtherscanLink/style.js @@ -1,11 +1,30 @@ // @flow +import { secondaryText } from '~/theme/variables' export const styles = () => ({ etherscanLink: { display: 'flex', alignItems: 'center', + + '& svg': { + fill: secondaryText, + }, + }, + address: { + display: 'block', + flexShrink: '1', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }, addressParagraph: { fontSize: '13px', }, + button: { + height: '24px', + margin: '0', + width: '24px', + }, + firstButton: { + marginLeft: '8px', + }, }) diff --git a/src/logic/addressBook/store/actions/saveAndUpdateAddressBook.js b/src/logic/addressBook/store/actions/saveAndUpdateAddressBook.js index 77413003..31b03a30 100644 --- a/src/logic/addressBook/store/actions/saveAndUpdateAddressBook.js +++ b/src/logic/addressBook/store/actions/saveAndUpdateAddressBook.js @@ -2,12 +2,12 @@ import type { Dispatch as ReduxDispatch } from 'redux' import { type GlobalState } from '~/store/index' import { saveAddressBook } from '~/logic/addressBook/utils' -import { updateAddressBook } from '~/logic/addressBook/store/actions/updateAddressBook' +import { updateAddressBookEntry } from '~/logic/addressBook/store/actions/updateAddressBookEntry' import type { AddressBook } from '~/logic/addressBook/model/addressBook' const saveAndUpdateAddressBook = (addressBook: AddressBook) => async (dispatch: ReduxDispatch) => { try { - dispatch(updateAddressBook(addressBook)) + dispatch(updateAddressBookEntry(addressBook)) await saveAddressBook(addressBook) } catch (err) { // eslint-disable-next-line diff --git a/src/logic/notifications/notificationBuilder.js b/src/logic/notifications/notificationBuilder.js index e5684755..d9890779 100644 --- a/src/logic/notifications/notificationBuilder.js +++ b/src/logic/notifications/notificationBuilder.js @@ -10,11 +10,11 @@ import { type Notification, NOTIFICATIONS } from './notificationTypes' export type NotificationsQueue = { beforeExecution: Notification | null, pendingExecution: Notification | null, - waitingConfirmation: Notification | null, + waitingConfirmation?: Notification | null, afterExecution: { noMoreConfirmationsNeeded: Notification | null, moreConfirmationsNeeded: Notification | null, - }, + } | null, afterExecutionError: Notification | null, afterRejection: Notification | null, } @@ -56,7 +56,7 @@ const cancellationTxNotificationsQueue: NotificationsQueue = { afterRejection: NOTIFICATIONS.TX_REJECTED_MSG, afterExecution: { noMoreConfirmationsNeeded: NOTIFICATIONS.TX_EXECUTED_MSG, - moreConfirmationsNeeded: NOTIFICATIONS.TX_EXECUTED_MORE_CONFIRMATIONS_MSG, + moreConfirmationsNeeded: NOTIFICATIONS.TX_CANCELLATION_EXECUTED_MSG, }, afterExecutionError: NOTIFICATIONS.TX_FAILED_MSG, } @@ -200,19 +200,26 @@ export const enhanceSnackbarForAction = (notification: Notification, key?: strin options: { ...notification.options, onClick, - action: (key: number) => ( - store.dispatch(closeSnackbarAction({ key }))}> + action: (actionKey: number) => ( + store.dispatch(closeSnackbarAction({ key: actionKey }))}> ), }, }) -export const showSnackbar = (notification: Notification, enqueueSnackbar: Function, closeSnackbar: Function) => enqueueSnackbar(notification.message, { - ...notification.options, - action: (key) => ( - closeSnackbar(key)}> - - - ), -}) +export const showSnackbar = ( + notification: Notification, + enqueueSnackbar: Function, + closeSnackbar: Function, +) => enqueueSnackbar( + notification.message, + { + ...notification.options, + action: (key) => ( + closeSnackbar(key)}> + + + ), + }, +) diff --git a/src/logic/notifications/notificationTypes.js b/src/logic/notifications/notificationTypes.js index dc1c0488..96009a25 100644 --- a/src/logic/notifications/notificationTypes.js +++ b/src/logic/notifications/notificationTypes.js @@ -37,6 +37,7 @@ export type Notifications = { TX_PENDING_MSG: Notification, TX_REJECTED_MSG: Notification, TX_EXECUTED_MSG: Notification, + TX_CANCELLATION_EXECUTED_MSG: Notification, TX_EXECUTED_MORE_CONFIRMATIONS_MSG: Notification, TX_FAILED_MSG: Notification, TX_WAITING_MSG: Notification, @@ -57,6 +58,7 @@ export type Notifications = { SIGN_SETTINGS_CHANGE_MSG: Notification, SETTINGS_CHANGE_PENDING_MSG: Notification, SETTINGS_CHANGE_REJECTED_MSG: Notification, + SETTINGS_CHANGE_EXECUTED_MSG: Notification, SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: Notification, SETTINGS_CHANGE_FAILED_MSG: Notification, @@ -126,6 +128,10 @@ export const NOTIFICATIONS: Notifications = { message: 'Transaction successfully executed', options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration }, }, + TX_CANCELLATION_EXECUTED_MSG: { + message: 'Rejection successfully submitted', + options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration }, + }, TX_EXECUTED_MORE_CONFIRMATIONS_MSG: { message: 'Transaction successfully created. More confirmations needed to execute', options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration }, diff --git a/src/logic/safe/transactions/awaitingTransactions.js b/src/logic/safe/transactions/awaitingTransactions.js index 6a2e676a..112d112f 100644 --- a/src/logic/safe/transactions/awaitingTransactions.js +++ b/src/logic/safe/transactions/awaitingTransactions.js @@ -4,6 +4,7 @@ import type { Transaction } from '~/routes/safe/store/models/transaction' export const getAwaitingTransactions = ( allTransactions: Map>, + cancellationTransactionsByNonce: Map>, userAccount: string, ): Map> => { if (!allTransactions) { @@ -16,17 +17,15 @@ export const getAwaitingTransactions = ( // If transactions are not executed, but there's a transaction with the same nonce EXECUTED later // it means that the transaction was cancelled (Replaced) and shouldn't get executed if (!transaction.isExecuted) { - const replacementTransaction = safeTransactions.findLast( - (tx) => tx.isExecuted && tx.nonce === transaction.nonce, - ) - if (replacementTransaction) { + if (cancellationTransactionsByNonce.get(transaction.nonce)) { // eslint-disable-next-line no-param-reassign transaction = transaction.set('cancelled', true) } } // The transaction is not executed and is not cancelled, so it's still waiting confirmations if (!transaction.executionTxHash && !transaction.cancelled) { - // Then we check if the waiting confirmations are not from the current user, otherwise, filters this transaction + // Then we check if the waiting confirmations are not from the current user, otherwise, filters this + // transaction const transactionWaitingUser = transaction.confirmations.filter( (confirmation) => confirmation.owner && confirmation.owner.address !== userAccount, ) diff --git a/src/logic/safe/transactions/notifiedTransactions.js b/src/logic/safe/transactions/notifiedTransactions.js index 3c8213ef..304ea571 100644 --- a/src/logic/safe/transactions/notifiedTransactions.js +++ b/src/logic/safe/transactions/notifiedTransactions.js @@ -9,7 +9,6 @@ export type NotifiedTransaction = { SAFE_NAME_CHANGE_TX: string, OWNER_NAME_CHANGE_TX: string, ADDRESSBOOK_NEW_ENTRY: string, - ADDRESSBOOK_EDIT_ENTRY: string, ADDRESSBOOK_DELETE_ENTRY: string, } diff --git a/src/logic/tokens/utils/tokenHelpers.js b/src/logic/tokens/utils/tokenHelpers.js index 7723664a..ea742087 100644 --- a/src/logic/tokens/utils/tokenHelpers.js +++ b/src/logic/tokens/utils/tokenHelpers.js @@ -49,4 +49,4 @@ export const isAddressAToken = async (tokenAddress: string) => { return call !== '0x' } -export const isTokenTransfer = async (data: string, value: number) => data && data.substring(0, 10) === '0xa9059cbb' && value === 0 +export const isTokenTransfer = (data: string, value: number): boolean => !!data && data.substring(0, 10) === '0xa9059cbb' && value === 0 diff --git a/src/routes/safe/components/Layout.jsx b/src/routes/safe/components/Layout.jsx index 16ff6e92..296d50a2 100644 --- a/src/routes/safe/components/Layout.jsx +++ b/src/routes/safe/components/Layout.jsx @@ -71,6 +71,7 @@ const Layout = (props: Props) => { fetchTokens, updateSafe, transactions, + cancellationTransactions, userAddress, sendFunds, showReceive, @@ -190,6 +191,7 @@ const Layout = (props: Props) => { owners={safe.owners} nonce={safe.nonce} transactions={transactions} + cancellationTransactions={cancellationTransactions} safeAddress={address} userAddress={userAddress} currentNetwork={network} diff --git a/src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell/index.jsx b/src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell/index.jsx index b5c726aa..f2d98f78 100644 --- a/src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell/index.jsx +++ b/src/routes/safe/components/Settings/ManageOwners/OwnerAddressTableCell/index.jsx @@ -20,7 +20,7 @@ const OwnerAddressTableCell = (props: Props) => { { showLinks ? ( -
+
{ userName }
diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/ApproveTxModal/index.jsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/ApproveTxModal/index.jsx index 502d90da..bea46844 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/ApproveTxModal/index.jsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/ApproveTxModal/index.jsx @@ -21,11 +21,13 @@ import { type Transaction } from '~/routes/safe/store/models/transaction' import { styles } from './style' export const APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID = 'approve-tx-modal-submit-btn' +export const REJECT_TX_MODAL_SUBMIT_BTN_TEST_ID = 'reject-tx-modal-submit-btn' type Props = { onClose: () => void, classes: Object, isOpen: boolean, + isCancelTx?: boolean, processTransaction: Function, tx: Transaction, nonce: string, @@ -38,23 +40,31 @@ type Props = { closeSnackbar: Function } -const getModalTitleAndDescription = (thresholdReached: boolean) => { - const title = thresholdReached ? 'Execute Transaction' : 'Approve Transaction' - const description = `This action will ${ - thresholdReached ? 'execute' : 'approve' - } this transaction. A separate transaction will be performed to submit the ${ - thresholdReached ? 'execution' : 'approval' - }.` - - return { - title, - description, +const getModalTitleAndDescription = (thresholdReached: boolean, isCancelTx?: boolean) => { + const modalInfo = { + title: 'Execute Transaction Rejection', + description: 'This action will execute this transaction.', } + + if (isCancelTx) { + return modalInfo + } + + if (thresholdReached) { + modalInfo.title = 'Execute Transaction' + modalInfo.description = 'This action will execute this transaction. A separate Transaction will be performed to submit the execution.' + } else { + modalInfo.title = 'Approve Transaction' + modalInfo.description = 'This action will approve this transaction. A separate Transaction will be performed to submit the approval.' + } + + return modalInfo } const ApproveTxModal = ({ onClose, isOpen, + isCancelTx, classes, processTransaction, tx, @@ -68,7 +78,7 @@ const ApproveTxModal = ({ }: Props) => { const [approveAndExecute, setApproveAndExecute] = useState(canExecute) const [gasCosts, setGasCosts] = useState('< 0.001') - const { title, description } = getModalTitleAndDescription(thresholdReached) + const { title, description } = getModalTitleAndDescription(thresholdReached, isCancelTx) const oneConfirmationLeft = !thresholdReached && tx.confirmations.size + 1 === threshold const isTheTxReadyToBeExecuted = oneConfirmationLeft ? true : thresholdReached @@ -132,7 +142,7 @@ const ApproveTxModal = ({ - + {description} Transaction nonce: @@ -142,20 +152,22 @@ const ApproveTxModal = ({ {oneConfirmationLeft && canExecute && ( <> - Approving this transaction executes it right away. If you want - approve but execute the transaction manually later, click on the - checkbox below. + Approving this transaction executes it right away. + {!isCancelTx && ' If you want approve but execute the transaction manually later, click on the ' + + 'checkbox below.'} - - )} - label="Execute transaction" - /> + {!isCancelTx && ( + + )} + label="Execute transaction" + /> + )} )} @@ -176,9 +188,9 @@ const ApproveTxModal = ({ variant="contained" minWidth={214} minHeight={42} - color="primary" + color={isCancelTx ? 'secondary' : 'primary'} onClick={approveTx} - testId={APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID} + testId={isCancelTx ? REJECT_TX_MODAL_SUBMIT_BTN_TEST_ID : APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID} > {title} diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/ButtonRow.jsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/ButtonRow.jsx deleted file mode 100644 index 8bc095ee..00000000 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/ButtonRow.jsx +++ /dev/null @@ -1,45 +0,0 @@ -// @flow -import React from 'react' -import { withStyles } from '@material-ui/core/styles' -import Row from '~/components/layout/Row' -import Button from '~/components/layout/Button' -import { xl, sm, border } from '~/theme/variables' - -type Props = { - classes: Object, - onTxCancel: Function, - showCancelBtn: boolean, -} - -const styles = () => ({ - buttonRow: { - borderTop: `2px solid ${border}`, - display: 'flex', - justifyContent: 'flex-end', - padding: '10px 20px', - }, - button: { - height: xl, - }, - icon: { - width: '14px', - height: '14px', - marginRight: sm, - }, -}) - -const ButtonRow = ({ - classes, - onTxCancel, - showCancelBtn, -}: Props) => ( - - {showCancelBtn && ( - - )} - -) - -export default withStyles(styles)(ButtonRow) diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.jsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.jsx index 4c6d50f6..44f485be 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.jsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnerComponent.jsx @@ -1,6 +1,7 @@ // @flow import React from 'react' import { withStyles } from '@material-ui/core/styles' +import cn from 'classnames' import Button from '~/components/layout/Button' import Img from '~/components/layout/Img' import EtherscanLink from '~/components/EtherscanLink' @@ -9,58 +10,70 @@ import Block from '~/components/layout/Block' import Paragraph from '~/components/layout/Paragraph' import { type Owner } from '~/routes/safe/store/models/owner' import { styles } from './style' -import ConfirmSmallGreyIcon from './assets/confirm-small-grey.svg' -import ConfirmSmallGreenIcon from './assets/confirm-small-green.svg' -import ConfirmSmallFilledIcon from './assets/confirm-small-filled.svg' +import ConfirmSmallGreyCircle from './assets/confirm-small-grey.svg' +import ConfirmSmallGreenCircle from './assets/confirm-small-green.svg' +import ConfirmSmallFilledCircle from './assets/confirm-small-filled.svg' +import ConfirmSmallRedCircle from './assets/confirm-small-red.svg' +import CancelSmallFilledCircle from './assets/cancel-small-filled.svg' import { getNameFromAddressBook } from '~/logic/addressBook/utils' export const CONFIRM_TX_BTN_TEST_ID = 'confirm-btn' export const EXECUTE_TX_BTN_TEST_ID = 'execute-btn' +export const REJECT_TX_BTN_TEST_ID = 'reject-btn' +export const EXECUTE_REJECT_TX_BTN_TEST_ID = 'execute-reject-btn' type OwnerProps = { - owner: Owner, classes: Object, - userAddress: string, confirmed?: boolean, executor?: string, - thresholdReached: boolean, + isCancelTx?: boolean, + onTxReject?: Function, + onTxConfirm: Function, + onTxExecute: Function, + owner: Owner, + showRejectBtn: boolean, + showExecuteRejectBtn: boolean, showConfirmBtn: boolean, showExecuteBtn: boolean, - onTxConfirm: Function, - onTxExecute: Function + thresholdReached: boolean, + userAddress: string, } const OwnerComponent = ({ - owner, - userAddress, + onTxReject, classes, + confirmed, + executor, + isCancelTx, onTxConfirm, + onTxExecute, + owner, + showRejectBtn, + showExecuteRejectBtn, showConfirmBtn, showExecuteBtn, - onTxExecute, - executor, - confirmed, thresholdReached, + userAddress, }: OwnerProps) => { const nameInAdbk = getNameFromAddressBook(owner.address) const ownerName = nameInAdbk || owner.name + const [imgCircle, setImgCircle] = React.useState(ConfirmSmallGreyCircle) + + React.useMemo(() => { + if (confirmed) { + setImgCircle(isCancelTx ? CancelSmallFilledCircle : ConfirmSmallFilledCircle) + } else if (thresholdReached || executor) { + setImgCircle(isCancelTx ? ConfirmSmallRedCircle : ConfirmSmallGreenCircle) + } + }, [confirmed, thresholdReached, executor, isCancelTx]) + + const getTimelineLine = () => (isCancelTx ? classes.verticalLineCancel : classes.verticalLineDone) + return ( -
-
- {confirmed ? ( - - ) : thresholdReached || executor ? ( - - ) : ( - - )} +
+
+
@@ -75,29 +88,60 @@ const OwnerComponent = ({ /> - {showConfirmBtn && owner.address === userAddress && ( - - )} - {showExecuteBtn && owner.address === userAddress && ( - + {owner.address === userAddress && ( + + {isCancelTx ? ( + <> + {showRejectBtn && ( + + )} + {showExecuteRejectBtn && ( + + )} + + ) : ( + <> + {showConfirmBtn && ( + + )} + {showExecuteBtn && ( + + )} + + )} + )} {owner.address === executor && ( Executor diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.jsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.jsx index ad60f0b5..2ff193ea 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.jsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/OwnersList.jsx @@ -7,56 +7,72 @@ import { type Owner } from '~/routes/safe/store/models/owner' import { styles } from './style' type ListProps = { - ownersWhoConfirmed: List, - ownersUnconfirmed: List, classes: Object, - userAddress: string, executor: string, - thresholdReached: boolean, - showConfirmBtn: boolean, - showExecuteBtn: boolean, + isCancelTx?: boolean, + onTxReject?: Function, onTxConfirm: Function, onTxExecute: Function, + ownersUnconfirmed: List, + ownersWhoConfirmed: List, + showRejectBtn: boolean, + showExecuteRejectBtn: boolean, + showConfirmBtn: boolean, + showExecuteBtn: boolean, + thresholdReached: boolean, + userAddress: string, } const OwnersList = ({ - userAddress, - ownersWhoConfirmed, - ownersUnconfirmed, classes, executor, - thresholdReached, - showConfirmBtn, - showExecuteBtn, + isCancelTx, + onTxReject, onTxConfirm, onTxExecute, + ownersUnconfirmed, + ownersWhoConfirmed, + showRejectBtn, + showExecuteRejectBtn, + showConfirmBtn, + showExecuteBtn, + thresholdReached, + userAddress, }: ListProps) => ( <> {ownersWhoConfirmed.map((owner) => ( ))} {ownersUnconfirmed.map((owner) => ( ))} diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/cancel-small-filled.svg b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/cancel-small-filled.svg new file mode 100644 index 00000000..8c2b45dd --- /dev/null +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/cancel-small-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/check-large-filled-red.svg b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/check-large-filled-red.svg new file mode 100644 index 00000000..191eb63a --- /dev/null +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/check-large-filled-red.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/confirm-large-red.svg b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/confirm-large-red.svg new file mode 100644 index 00000000..e5ddd508 --- /dev/null +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/confirm-large-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/confirm-small-red.svg b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/confirm-small-red.svg new file mode 100644 index 00000000..4e9f8350 --- /dev/null +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/assets/confirm-small-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.jsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.jsx index b6394857..93b7af29 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.jsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/index.jsx @@ -7,71 +7,111 @@ import Block from '~/components/layout/Block' import Col from '~/components/layout/Col' import Img from '~/components/layout/Img' import { type Owner } from '~/routes/safe/store/models/owner' -import { type Transaction } from '~/routes/safe/store/models/transaction' +import { + makeTransaction, + type Transaction, +} from '~/routes/safe/store/models/transaction' import { TX_TYPE_CONFIRMATION } from '~/logic/safe/transactions/send' -import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import OwnersList from './OwnersList' -import ButtonRow from './ButtonRow' -import CheckLargeFilledGreenIcon from './assets/check-large-filled-green.svg' -import ConfirmLargeGreenIcon from './assets/confirm-large-green.svg' -import ConfirmLargeGreyIcon from './assets/confirm-large-grey.svg' +import CheckLargeFilledGreenCircle from './assets/check-large-filled-green.svg' +import ConfirmLargeGreenCircle from './assets/confirm-large-green.svg' +import CheckLargeFilledRedCircle from './assets/check-large-filled-red.svg' +import ConfirmLargeRedCircle from './assets/confirm-large-red.svg' +import ConfirmLargeGreyCircle from './assets/confirm-large-grey.svg' import { styles } from './style' import Paragraph from '~/components/layout/Paragraph/index' type Props = { - tx: Transaction, - owners: List, + canExecute: boolean, + canExecuteCancel: boolean, + cancelThresholdReached: boolean, + cancelTx: Transaction, classes: Object, granted: boolean, - threshold: number, - userAddress: string, - thresholdReached: boolean, - safeAddress: string, - canExecute: boolean, + onTxReject: Function, onTxConfirm: Function, - onTxCancel: Function, - onTxExecute: Function + onTxExecute: Function, + owners: List, + threshold: number, + thresholdReached: boolean, + tx: Transaction, + userAddress: string, +}; + +function getOwnersConfirmations(tx, userAddress) { + const ownersWhoConfirmed = [] + let currentUserAlreadyConfirmed = false + + tx.confirmations.forEach((conf) => { + if (conf.owner.address === userAddress) { + currentUserAlreadyConfirmed = true + } + + if (conf.type === TX_TYPE_CONFIRMATION) { + ownersWhoConfirmed.push(conf.owner) + } + }) + + return [ownersWhoConfirmed, currentUserAlreadyConfirmed] } -const isCancellationTransaction = (tx: Transaction, safeAddress: string) => !tx.value && tx.data === EMPTY_DATA && tx.recipient === safeAddress +function getPendingOwnersConfirmations(owners, tx, userAddress) { + const ownersUnconfirmed = owners.filter( + (owner) => tx.confirmations.findIndex( + (conf) => conf.owner.address === owner.address, + ) === -1, + ) + + let userIsUnconfirmedOwner = false + + ownersUnconfirmed.some((owner) => { + userIsUnconfirmedOwner = owner.address === userAddress + return userIsUnconfirmedOwner + }) + + return [ownersUnconfirmed, userIsUnconfirmedOwner] +} const OwnersColumn = ({ tx, + cancelTx = makeTransaction(), owners, classes, granted, threshold, userAddress, thresholdReached, - safeAddress, + cancelThresholdReached, onTxConfirm, - onTxCancel, onTxExecute, + onTxReject, canExecute, + canExecuteCancel, }: Props) => { - const cancellationTx = isCancellationTransaction(tx, safeAddress) - const showOlderTxAnnotation = thresholdReached && !canExecute && !tx.isExecuted + let showOlderTxAnnotation: boolean - const ownersWhoConfirmed = [] - let currentUserAlreadyConfirmed = false - tx.confirmations.forEach((conf) => { - if (conf.owner.address === userAddress) { - currentUserAlreadyConfirmed = true - } - if (conf.type === TX_TYPE_CONFIRMATION) { - ownersWhoConfirmed.push(conf.owner) - } - }) - const ownersUnconfirmed = owners.filter( - (owner) => tx.confirmations.findIndex( - (conf) => conf.owner.address === owner.address, - ) === -1, - ) - let userIsUnconfirmedOwner - ownersUnconfirmed.some((owner) => { - userIsUnconfirmedOwner = owner.address === userAddress - return userIsUnconfirmedOwner - }) + if (tx.isExecuted || cancelTx.isExecuted) { + showOlderTxAnnotation = false + } else { + showOlderTxAnnotation = (thresholdReached && !canExecute) || (cancelThresholdReached && !canExecuteCancel) + } + + const [ + ownersWhoConfirmed, + currentUserAlreadyConfirmed, + ] = getOwnersConfirmations(tx, userAddress) + const [ + ownersUnconfirmed, + userIsUnconfirmedOwner, + ] = getPendingOwnersConfirmations(owners, tx, userAddress) + const [ + ownersWhoConfirmedCancel, + currentUserAlreadyConfirmedCancel, + ] = getOwnersConfirmations(cancelTx, userAddress) + const [ + ownersUnconfirmedCancel, + userIsUnconfirmedCancelOwner, + ] = getPendingOwnersConfirmations(owners, cancelTx, userAddress) let displayButtonRow = true if (tx.executionTxHash) { @@ -80,13 +120,7 @@ const OwnersColumn = ({ } else if (tx.status === 'cancelled') { // tx is cancelled (replaced) by another one displayButtonRow = false - } else if ( - cancellationTx - && currentUserAlreadyConfirmed - && !thresholdReached - ) { - // the TX is the cancellation (replacement) transaction for previous TX, - // current user has already confirmed it and threshold is not reached (so he can't execute/cancel it) + } else if (currentUserAlreadyConfirmedCancel) { displayButtonRow = false } @@ -99,6 +133,19 @@ const OwnersColumn = ({ const showExecuteBtn = canExecute && !tx.isExecuted && thresholdReached + const showRejectBtn = !cancelTx.isExecuted + && !tx.isExecuted + && cancelTx.status !== 'pending' + && userIsUnconfirmedCancelOwner + && !currentUserAlreadyConfirmedCancel + && !cancelThresholdReached + && displayButtonRow + + const showExecuteRejectBtn = !cancelTx.isExecuted && !tx.isExecuted && canExecuteCancel && cancelThresholdReached + + const txThreshold = cancelTx.isExecuted ? tx.confirmations.size : threshold + const cancelThreshold = tx.isExecuted ? cancelTx.confirmations.size : threshold + return ( -
- {thresholdReached || tx.isExecuted ? ( - - ) : ( - - )} +
+
{tx.isExecuted ? `Confirmed [${tx.confirmations.size}/${tx.confirmations.size}]` - : `Confirmed [${tx.confirmations.size}/${threshold}]`} + : `Confirmed [${tx.confirmations.size}/${txThreshold}]`} + {/* Cancel TX thread - START */} + +
+
+ +
+ {cancelTx.isExecuted + ? `Rejected [${cancelTx.confirmations.size}/${cancelTx.confirmations.size}]` + : `Rejected [${cancelTx.confirmations.size}/${cancelThreshold}]`} + + + {/* Cancel TX thread - END */}
-
- {!thresholdReached && !tx.isExecuted && ( - Confirm tx + className={cn( + classes.verticalLine, + tx.isExecuted && classes.verticalLineDone, + cancelTx.isExecuted && classes.verticalLineCancel, )} - {thresholdReached && !tx.isExecuted && ( - Execute tx + /> + +
+ {!tx.isExecuted && !cancelTx.isExecuted && ( + Confirm / Execute tx )} {tx.isExecuted && ( - TX Executed icon + TX Executed icon + )} + {cancelTx.isExecuted && ( + TX Executed icon )}
Executed + {showOlderTxAnnotation && ( @@ -162,9 +240,6 @@ const OwnersColumn = ({ )} - {granted && displayButtonRow && ( - - )} ) } diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.js b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.js index baae9af5..ce16b87e 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.js +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/OwnersColumn/style.js @@ -1,34 +1,36 @@ // @flow import { - border, sm, boldFont, primary, secondary, secondaryText, + border, sm, boldFont, primary, secondary, secondaryText, error, } from '~/theme/variables' export const styles = () => ({ ownersList: { - width: '100%', - padding: 0, height: '192px', overflowY: 'scroll', + padding: 0, + width: '100%', }, rightCol: { - boxSizing: 'border-box', borderLeft: `2px solid ${border}`, + boxSizing: 'border-box', }, - verticalLineProgressPending: { + verticalLine: { + backgroundColor: secondaryText, + height: '55px', + left: '27px', position: 'absolute', - borderLeft: `2px solid ${secondaryText}`, - height: '52px', - top: '-26px', - left: '29px', + top: '-27px', + width: '2px', zIndex: '10', }, - verticalLineProgressDone: { - position: 'absolute', - borderLeft: `2px solid ${secondary}`, - height: '52px', - top: '-26px', - left: '29px', - zIndex: '10', + verticalLinePending: { + backgroundColor: secondaryText, + }, + verticalLineDone: { + backgroundColor: secondary, + }, + verticalLineCancel: { + backgroundColor: error, }, icon: { marginRight: sm, @@ -37,21 +39,21 @@ export const styles = () => ({ borderBottom: `1px solid ${border}`, }, container: { - position: 'relative', + alignItems: 'center', display: 'flex', - padding: '5px 20px', + padding: '13px 15px 13px 18px', + position: 'relative', }, ownerListTitle: { - position: 'relative', - display: 'flex', alignItems: 'center', - padding: '15px', - paddingLeft: '20px', + display: 'flex', fontSize: '11px', fontWeight: boldFont, - lineHeight: 1.27, - textTransform: 'uppercase', letterSpacing: '1px', + lineHeight: 1.3, + padding: '15px 15px 15px 18px', + position: 'relative', + textTransform: 'uppercase', }, olderTxAnnotation: { textAlign: 'center', @@ -59,10 +61,13 @@ export const styles = () => ({ ownerListTitleDone: { color: secondary, }, + ownerListTitleCancelDone: { + color: error, + }, name: { - textOverflow: 'ellipsis', - overflow: 'hidden', height: '15px', + overflow: 'hidden', + textOverflow: 'ellipsis', }, address: { height: '20px', @@ -70,26 +75,37 @@ export const styles = () => ({ spacer: { flex: 'auto', }, - iconState: { - width: '20px', + circleState: { display: 'flex', justifyContent: 'center', - marginRight: '10px', + marginRight: '18px', + width: '20px', zIndex: '100', + '& > img': { display: 'block', }, }, button: { - justifyContent: 'center', alignSelf: 'center', + flexGrow: '0', + fontSize: '16px', + justifyContent: 'center', + paddingLeft: '14px', + paddingRight: '14px', + }, + lastButton: { + marginLeft: '10px', }, executor: { - borderRadius: '3px', - padding: '3px 5px', - background: border, - color: primary, alignSelf: 'center', + background: border, + borderRadius: '3px', + color: primary, fontSize: '11px', + height: '24px', + lineHeight: '24px', + padding: '0 12px', + }, }) diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CancelTxModal/index.jsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/index.jsx similarity index 91% rename from src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CancelTxModal/index.jsx rename to src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/index.jsx index e55eb1aa..db495f7a 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CancelTxModal/index.jsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/index.jsx @@ -30,7 +30,7 @@ type Props = { closeSnackbar: Function, } -const CancelTxModal = ({ +const RejectTxModal = ({ onClose, isOpen, classes, @@ -78,15 +78,14 @@ const CancelTxModal = ({ return ( - Cancel transaction + Reject transaction @@ -96,8 +95,7 @@ const CancelTxModal = ({ - This action will cancel this transaction. A separate transaction will be performed to submit the - cancellation. + This action will cancel this transaction. A separate transaction will be performed to submit the rejection. Transaction nonce: @@ -123,11 +121,11 @@ const CancelTxModal = ({ color="secondary" onClick={sendReplacementTransaction} > - Cancel Transaction + Reject Transaction ) } -export default withStyles(styles)(withSnackbar(CancelTxModal)) +export default withStyles(styles)(withSnackbar(RejectTxModal)) diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CancelTxModal/style.js b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/style.js similarity index 100% rename from src/routes/safe/components/Transactions/TxsTable/ExpandedTx/CancelTxModal/style.js rename to src/routes/safe/components/Transactions/TxsTable/ExpandedTx/RejectTxModal/style.js diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.jsx b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.jsx index 9a2a8418..9dfb1fb1 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.jsx +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/index.jsx @@ -15,7 +15,7 @@ import { type Transaction } from '~/routes/safe/store/models/transaction' import { type Owner } from '~/routes/safe/store/models/owner' import TxDescription from './TxDescription' import OwnersColumn from './OwnersColumn' -import CancelTxModal from './CancelTxModal' +import RejectTxModal from './RejectTxModal' import ApproveTxModal from './ApproveTxModal' import { styles } from './style' import { formatDate } from '../columns' @@ -24,6 +24,7 @@ import { INCOMING_TX_TYPE } from '~/routes/safe/store/models/incomingTransaction type Props = { tx: Transaction, + cancelTx: Transaction, threshold: number, owners: List, granted: boolean, @@ -34,12 +35,13 @@ type Props = { nonce: number } -type OpenModal = "cancelTx" | "approveTx" | null +type OpenModal = "rejectTx" | "approveTx" | "executeRejectTx" | null const useStyles = makeStyles(styles) const ExpandedTx = ({ tx, + cancelTx, threshold, owners, granted, @@ -52,10 +54,19 @@ const ExpandedTx = ({ const classes = useStyles() const [openModal, setOpenModal] = useState(null) const openApproveModal = () => setOpenModal('approveTx') - const openCancelModal = () => setOpenModal('cancelTx') const closeModal = () => setOpenModal(null) const thresholdReached = tx.type !== INCOMING_TX_TYPE && threshold <= tx.confirmations.size const canExecute = tx.type !== INCOMING_TX_TYPE && nonce === tx.nonce + const cancelThresholdReached = !!cancelTx && threshold <= cancelTx.confirmations.size + const canExecuteCancel = nonce === tx.nonce + + const openRejectModal = () => { + if (!!cancelTx && nonce === cancelTx.nonce) { + setOpenModal('executeRejectTx') + } else { + setOpenModal('rejectTx') + } + } return ( <> @@ -136,29 +147,23 @@ const ExpandedTx = ({ {tx.type !== INCOMING_TX_TYPE && ( )} - {openModal === 'cancelTx' && ( - - )} {openModal === 'approveTx' && ( )} + {openModal === 'rejectTx' && ( + + )} + {openModal === 'executeRejectTx' && ( + + )} ) } diff --git a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/style.js b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/style.js index 3b378d80..aad5ef4d 100644 --- a/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/style.js +++ b/src/routes/safe/components/Transactions/TxsTable/ExpandedTx/style.js @@ -11,8 +11,9 @@ export const styles = () => ({ padding: `${lg} ${md}`, }, txData: { - display: 'flex', alignItems: 'center', + display: 'flex', + lineHeight: '1.6', }, awaiting_your_confirmation: { color: disabled, diff --git a/src/routes/safe/components/Transactions/TxsTable/__tests__/column.test.js b/src/routes/safe/components/Transactions/TxsTable/__tests__/column.test.js new file mode 100644 index 00000000..6fffef7b --- /dev/null +++ b/src/routes/safe/components/Transactions/TxsTable/__tests__/column.test.js @@ -0,0 +1,31 @@ +// @flow +import { List } from 'immutable' +import { makeTransaction } from '~/routes/safe/store/models/transaction' +import { getTxTableData, TX_TABLE_RAW_CANCEL_TX_ID } from '~/routes/safe/components/Transactions/TxsTable/columns' + +describe('TxsTable Columns > getTxTableData', () => { + it('should include CancelTx object inside TxTableData', () => { + // Given + const mockedTransaction = makeTransaction({ nonce: 1, blockNumber: 100 }) + const mockedCancelTransaction = makeTransaction({ nonce: 1, blockNumber: 123 }) + + // When + const txTableData = getTxTableData(List([mockedTransaction]), List([mockedCancelTransaction])) + const txRow = txTableData.first() + + // Then + expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toEqual(mockedCancelTransaction) + }) + it('should not include CancelTx object inside TxTableData', () => { + // Given + const mockedTransaction = makeTransaction({ nonce: 1, blockNumber: 100 }) + const mockedCancelTransaction = makeTransaction({ nonce: 2, blockNumber: 123 }) + + // When + const txTableData = getTxTableData(List([mockedTransaction]), List([mockedCancelTransaction])) + const txRow = txTableData.first() + + // Then + expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toBeUndefined() + }) +}) diff --git a/src/routes/safe/components/Transactions/TxsTable/columns.js b/src/routes/safe/components/Transactions/TxsTable/columns.js index 36c90b87..1105e287 100644 --- a/src/routes/safe/components/Transactions/TxsTable/columns.js +++ b/src/routes/safe/components/Transactions/TxsTable/columns.js @@ -2,7 +2,7 @@ import React from 'react' import { format, getTime, parseISO } from 'date-fns' import { BigNumber } from 'bignumber.js' -import { List } from 'immutable' +import { List, Map } from 'immutable' import TxType from './TxType' import { type Transaction } from '~/routes/safe/store/models/transaction' import { INCOMING_TX_TYPE, type IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction' @@ -16,10 +16,11 @@ export const TX_TABLE_DATE_ID = 'date' export const TX_TABLE_AMOUNT_ID = 'amount' export const TX_TABLE_STATUS_ID = 'status' export const TX_TABLE_RAW_TX_ID = 'tx' +export const TX_TABLE_RAW_CANCEL_TX_ID = 'cancelTx' export const TX_TABLE_EXPAND_ICON = 'expand' type TxData = { - id: number, + id: ?number, type: React.ReactNode, date: string, dateOrder?: number, @@ -63,7 +64,10 @@ const getIncomingTxTableData = (tx: IncomingTransaction): TransactionRow => ({ [TX_TABLE_RAW_TX_ID]: tx, }) -const getTransactionTableData = (tx: Transaction): TransactionRow => { +const getTransactionTableData = ( + tx: Transaction, + cancelTx: ?Transaction, +): TransactionRow => { const txDate = tx.submissionDate let txType = 'outgoing' @@ -85,16 +89,27 @@ const getTransactionTableData = (tx: Transaction): TransactionRow => { [TX_TABLE_AMOUNT_ID]: getTxAmount(tx), [TX_TABLE_STATUS_ID]: tx.status, [TX_TABLE_RAW_TX_ID]: tx, + [TX_TABLE_RAW_CANCEL_TX_ID]: cancelTx, } } -export const getTxTableData = (transactions: List): List => transactions.map((tx) => { - if (tx.type === INCOMING_TX_TYPE) { - return getIncomingTxTableData(tx) - } +export const getTxTableData = ( + transactions: List, + cancelTxs: List, +): List => { + const cancelTxsByNonce = cancelTxs.reduce((acc, tx) => acc.set(tx.nonce, tx), Map()) - return getTransactionTableData(tx) -}) + return transactions.map((tx) => { + if (tx.type === INCOMING_TX_TYPE) { + return getIncomingTxTableData(tx) + } + + return getTransactionTableData( + tx, + Number.isInteger(Number.parseInt(tx.nonce, 10)) ? cancelTxsByNonce.get(tx.nonce) : undefined, + ) + }) +} export const generateColumns = () => { const nonceColumn: Column = { diff --git a/src/routes/safe/components/Transactions/TxsTable/index.jsx b/src/routes/safe/components/Transactions/TxsTable/index.jsx index 0c10896e..5b2fbde5 100644 --- a/src/routes/safe/components/Transactions/TxsTable/index.jsx +++ b/src/routes/safe/components/Transactions/TxsTable/index.jsx @@ -21,10 +21,12 @@ import { generateColumns, TX_TABLE_ID, TX_TABLE_RAW_TX_ID, + TX_TABLE_RAW_CANCEL_TX_ID, type TransactionRow, } from './columns' import { styles } from './style' import Status from './Status' +import type { IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction' export const TRANSACTION_ROW_TEST_ID = 'transaction-row' @@ -35,7 +37,8 @@ const expandCellStyle = { type Props = { classes: Object, - transactions: List, + transactions: List, + cancellationTransactions: List, threshold: number, owners: List, userAddress: string, @@ -49,6 +52,7 @@ type Props = { const TxsTable = ({ classes, transactions, + cancellationTransactions, threshold, owners, granted, @@ -66,7 +70,7 @@ const TxsTable = ({ const columns = generateColumns() const autoColumns = columns.filter((c) => !c.custom) - const filteredData = getTxTableData(transactions) + const filteredData = getTxTableData(transactions, cancellationTransactions) .sort(({ dateOrder: a }, { dateOrder: b }) => { if (!a || !b) { return 0 @@ -132,6 +136,7 @@ const TxsTable = ({ component={ExpandedTxComponent} unmountOnExit tx={row[TX_TABLE_RAW_TX_ID]} + cancelTx={row[TX_TABLE_RAW_CANCEL_TX_ID]} threshold={threshold} owners={owners} granted={granted} diff --git a/src/routes/safe/components/Transactions/index.jsx b/src/routes/safe/components/Transactions/index.jsx index 92b3426a..4ee94a2b 100644 --- a/src/routes/safe/components/Transactions/index.jsx +++ b/src/routes/safe/components/Transactions/index.jsx @@ -10,6 +10,7 @@ type Props = { safeAddress: string, threshold: number, transactions: List, + cancellationTransactions: List, owners: List, userAddress: string, granted: boolean, @@ -21,6 +22,7 @@ type Props = { const Transactions = ({ transactions = List(), + cancellationTransactions = List(), owners, threshold, userAddress, @@ -33,6 +35,7 @@ const Transactions = ({ }: Props) => ( { fetchTokens, updateSafe, transactions, + cancellationTransactions, currencySelected, fetchCurrencyValues, currencyValues, @@ -161,6 +162,7 @@ class SafeView extends React.Component { fetchTokens={fetchTokens} updateSafe={updateSafe} transactions={transactions} + cancellationTransactions={cancellationTransactions} sendFunds={sendFunds} showReceive={showReceive} onShow={this.onShow} diff --git a/src/routes/safe/container/selector.js b/src/routes/safe/container/selector.js index 67ab7e32..9ae8d4a9 100644 --- a/src/routes/safe/container/selector.js +++ b/src/routes/safe/container/selector.js @@ -7,6 +7,7 @@ import { safeBalancesSelector, safeBlacklistedTokensSelector, safeTransactionsSelector, + safeCancellationTransactionsSelector, safeIncomingTransactionsSelector, type RouterProps, type SafeSelectorProps, @@ -38,6 +39,7 @@ export type SelectorProps = { currencySelected: string, currencyValues: BalanceCurrencyType[], transactions: List, + cancellationTransactions: List, addressBook: AddressBook, } @@ -108,21 +110,15 @@ const extendedTransactionsSelector: Selector { + (safe, userAddress, transactions, cancellationTransactions, incomingTransactions) => { + const cancellationTransactionsByNonce = cancellationTransactions.reduce((acc, tx) => acc.set(tx.nonce, tx), Map()) const extendedTransactions = transactions.map((tx: Transaction) => { let extendedTx = tx - // If transactions are not executed, but there's a transaction with the same nonce submitted later - // it means that the transaction was cancelled (Replaced) and shouldn't get executed - let replacementTransaction if (!tx.isExecuted) { - replacementTransaction = transactions.size > 1 && transactions.findLast( - (transaction) => ( - transaction.isExecuted && transaction.nonce && transaction.nonce >= tx.nonce - ), - ) - if (replacementTransaction) { + if (cancellationTransactionsByNonce.get(tx.nonce) && cancellationTransactionsByNonce.get(tx.nonce).get('isExecuted')) { extendedTx = tx.set('cancelled', true) } } @@ -145,6 +141,7 @@ export default createStructuredSelector({ network: networkSelector, safeUrl: safeParamAddressSelector, transactions: extendedTransactionsSelector, + cancellationTransactions: safeCancellationTransactionsSelector, currencySelected: currentCurrencySelector, currencyValues: currencyValuesListSelector, addressBook: getAddressBook, diff --git a/src/routes/safe/store/actions/addCancellationTransactions.js b/src/routes/safe/store/actions/addCancellationTransactions.js new file mode 100644 index 00000000..605a39f2 --- /dev/null +++ b/src/routes/safe/store/actions/addCancellationTransactions.js @@ -0,0 +1,6 @@ +// @flow +import { createAction } from 'redux-actions' + +export const ADD_CANCELLATION_TRANSACTIONS = 'ADD_CANCELLATION_TRANSACTIONS' + +export const addCancellationTransactions = createAction(ADD_CANCELLATION_TRANSACTIONS) diff --git a/src/routes/safe/store/actions/createTransaction.js b/src/routes/safe/store/actions/createTransaction.js index c123e457..63a2d3e1 100644 --- a/src/routes/safe/store/actions/createTransaction.js +++ b/src/routes/safe/store/actions/createTransaction.js @@ -78,7 +78,9 @@ const createTransaction = ({ const from = userAccountSelector(state) const safeInstance = await getGnosisSafeInstanceAt(safeAddress) const threshold = await safeInstance.getThreshold() - const nonce = txNonce || await getLastPendingTxNonce(safeAddress) + const nonce = !Number.isInteger(Number.parseInt(txNonce, 10)) + ? await getLastPendingTxNonce(safeAddress) + : txNonce const isExecution = threshold.toNumber() === 1 || shouldExecute // https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures diff --git a/src/routes/safe/store/actions/fetchTransactions.js b/src/routes/safe/store/actions/fetchTransactions.js index 1a8b4aa2..de87626e 100644 --- a/src/routes/safe/store/actions/fetchTransactions.js +++ b/src/routes/safe/store/actions/fetchTransactions.js @@ -1,5 +1,5 @@ // @flow -import { List, Map } from 'immutable' +import { List, Map, type RecordInstance } from 'immutable' import axios from 'axios' import bn from 'bignumber.js' import type { Dispatch as ReduxDispatch } from 'redux' @@ -20,6 +20,8 @@ import { getHumanFriendlyToken } from '~/logic/tokens/store/actions/fetchTokens' import { isTokenTransfer } from '~/logic/tokens/utils/tokenHelpers' import { decodeParamsFromSafeMethod } from '~/logic/contracts/methodIds' import { ALTERNATIVE_TOKEN_ABI } from '~/logic/tokens/utils/alternativeAbi' +import type { TransactionProps } from '~/routes/safe/store/models/transaction' +import { addCancellationTransactions } from '~/routes/safe/store/actions/addCancellationTransactions' let web3 @@ -33,22 +35,23 @@ type ConfirmationServiceModel = { type TxServiceModel = { to: string, value: number, - data: string, + data: ?string, operation: number, - nonce: number, - blockNumber: number, + nonce: ?number, + blockNumber: ?number, safeTxGas: number, baseGas: number, gasPrice: number, gasToken: string, refundReceiver: string, safeTxHash: string, - submissionDate: string, + submissionDate: ?string, executor: string, - executionDate: string, + executionDate: ?string, confirmations: ConfirmationServiceModel[], isExecuted: boolean, - transactionHash: string, + transactionHash: ?string, + creationTx?: boolean, } type IncomingTxServiceModel = { @@ -63,7 +66,7 @@ type IncomingTxServiceModel = { export const buildTransactionFrom = async ( safeAddress: string, tx: TxServiceModel, -) => { +): Promise => { const { owners } = await getLocalSafe(safeAddress) const confirmations = List( @@ -88,7 +91,7 @@ export const buildTransactionFrom = async ( ) const modifySettingsTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !!tx.data const cancellationTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !tx.data - const isSendTokenTx = await isTokenTransfer(tx.data, Number(tx.value)) + const isSendTokenTx = isTokenTransfer(tx.data, Number(tx.value)) const customTx = !sameAddress(tx.to, safeAddress) && !!tx.data && !isSendTokenTx let refundParams = null @@ -173,7 +176,8 @@ export const buildTransactionFrom = async ( }) } -const addMockSafeCreationTx = (safeAddress) => [{ +const addMockSafeCreationTx = (safeAddress): Array => [{ + blockNumber: null, baseGas: 0, confirmations: [], data: null, @@ -233,8 +237,14 @@ export const buildIncomingTransactionFrom = async (tx: IncomingTxServiceModel) = }) } -export const loadSafeTransactions = async (safeAddress: string) => { +export type SafeTransactionsType = { + outgoing: Map>, + cancel: Map>, +} + +export const loadSafeTransactions = async (safeAddress: string): Promise => { let transactions: TxServiceModel[] = addMockSafeCreationTx(safeAddress) + try { const url = buildTxServiceUrl(safeAddress) const response = await axios.get(url) @@ -245,11 +255,16 @@ export const loadSafeTransactions = async (safeAddress: string) => { console.error(`Requests for outgoing transactions for ${safeAddress} failed with 404`, err) } - const txsRecord = await Promise.all( + const txsRecord: Array> = await Promise.all( transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx)), ) - return Map().set(safeAddress, List(txsRecord)) + const groupedTxs = List(txsRecord).groupBy((tx) => (tx.get('cancellationTx') ? 'cancel' : 'outgoing')) + + return { + outgoing: Map().set(safeAddress, groupedTxs.get('outgoing')), + cancel: Map().set(safeAddress, groupedTxs.get('cancel')), + } } export const loadSafeIncomingTransactions = async (safeAddress: string) => { @@ -272,9 +287,10 @@ export const loadSafeIncomingTransactions = async (safeAddress: string) => { export default (safeAddress: string) => async (dispatch: ReduxDispatch) => { web3 = await getWeb3() - const transactions: Map> = await loadSafeTransactions(safeAddress) + const { outgoing, cancel }: SafeTransactionsType = await loadSafeTransactions(safeAddress) const incomingTransactions: Map> = await loadSafeIncomingTransactions(safeAddress) - dispatch(addTransactions(transactions)) + dispatch(addCancellationTransactions(cancel)) + dispatch(addTransactions(outgoing)) dispatch(addIncomingTransactions(incomingTransactions)) } diff --git a/src/routes/safe/store/middleware/notificationsMiddleware.js b/src/routes/safe/store/middleware/notificationsMiddleware.js index faf98ddd..77919c5e 100644 --- a/src/routes/safe/store/middleware/notificationsMiddleware.js +++ b/src/routes/safe/store/middleware/notificationsMiddleware.js @@ -1,6 +1,6 @@ // @flow import type { Action, Store } from 'redux' -import { List } from 'immutable' +import { List, Map } from 'immutable' import { push } from 'connected-react-router' import { type GlobalState } from '~/store/' import { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/addTransactions' @@ -32,8 +32,11 @@ const notificationsMiddleware = (store: Store) => ( const transactionsList = action.payload const userAddress: string = userAccountSelector(state) const safeAddress = action.payload.keySeq().get(0) + const cancellationTransactions = state.cancellationTransactions.get(safeAddress) + const cancellationTransactionsByNonce = cancellationTransactions ? cancellationTransactions.reduce((acc, tx) => acc.set(tx.nonce, tx), Map()) : Map() const awaitingTransactions = getAwaitingTransactions( transactionsList, + cancellationTransactionsByNonce, userAddress, ) const awaitingTransactionsList = awaitingTransactions.get( diff --git a/src/routes/safe/store/models/incomingTransaction.js b/src/routes/safe/store/models/incomingTransaction.js index d84a3550..c7caf01c 100644 --- a/src/routes/safe/store/models/incomingTransaction.js +++ b/src/routes/safe/store/models/incomingTransaction.js @@ -18,6 +18,27 @@ export type IncomingTransactionProps = { executionDate: string, type: string, status: string, + nonce: null, + confirmations: null, + recipient: null, + data: null, + operation: null, + safeTxGas: null, + baseGas: null, + gasPrice: null, + gasToken: null, + refundReceiver: null, + isExecuted: null, + submissionDate: null, + executor: null, + cancelled: null, + modifySettingsTx: null, + cancellationTx: null, + customTx: null, + creationTx: null, + isTokenTransfer: null, + decodedParams: null, + refundParams: null, } export const makeIncomingTransaction: RecordFactory = Record({ @@ -34,6 +55,27 @@ export const makeIncomingTransaction: RecordFactory = executionDate: '', type: INCOMING_TX_TYPE, status: 'success', + nonce: null, + confirmations: null, + recipient: null, + data: null, + operation: null, + safeTxGas: null, + baseGas: null, + gasPrice: null, + gasToken: null, + refundReceiver: null, + isExecuted: null, + submissionDate: null, + executor: null, + cancelled: null, + modifySettingsTx: null, + cancellationTx: null, + customTx: null, + creationTx: null, + isTokenTransfer: null, + decodedParams: null, + refundParams: null, }) export type IncomingTransaction = RecordOf diff --git a/src/routes/safe/store/models/transaction.js b/src/routes/safe/store/models/transaction.js index ad8bb6f9..25270b72 100644 --- a/src/routes/safe/store/models/transaction.js +++ b/src/routes/safe/store/models/transaction.js @@ -17,8 +17,8 @@ export type TransactionStatus = | 'pending' export type TransactionProps = { - nonce: number, - blockNumber: number, + nonce: ?number, + blockNumber: ?number, value: string, confirmations: List, recipient: string, @@ -30,8 +30,8 @@ export type TransactionProps = { gasToken: string, refundReceiver: string, isExecuted: boolean, - submissionDate: string, - executionDate: string, + submissionDate: ?string, + executionDate: ?string, symbol: string, modifySettingsTx: boolean, cancellationTx: boolean, @@ -39,7 +39,7 @@ export type TransactionProps = { creationTx: boolean, safeTxHash: string, executor: string, - executionTxHash?: string, + executionTxHash?: ?string, decimals?: number, cancelled?: boolean, status?: TransactionStatus, diff --git a/src/routes/safe/store/reducer/cancellationTransactions.js b/src/routes/safe/store/reducer/cancellationTransactions.js new file mode 100644 index 00000000..3a21ed21 --- /dev/null +++ b/src/routes/safe/store/reducer/cancellationTransactions.js @@ -0,0 +1,16 @@ +// @flow +import { List, Map } from 'immutable' +import { handleActions, type ActionType } from 'redux-actions' +import { ADD_CANCELLATION_TRANSACTIONS } from '~/routes/safe/store/actions/addCancellationTransactions' +import { type Transaction } from '~/routes/safe/store/models/transaction' + +export const CANCELLATION_TRANSACTIONS_REDUCER_ID = 'cancellationTransactions' + +export type CancelState = Map> + +export default handleActions( + { + [ADD_CANCELLATION_TRANSACTIONS]: (state: CancelState, action: ActionType): CancelState => action.payload, + }, + Map(), +) diff --git a/src/routes/safe/store/selectors/index.js b/src/routes/safe/store/selectors/index.js index 29be404d..1b30e27b 100644 --- a/src/routes/safe/store/selectors/index.js +++ b/src/routes/safe/store/selectors/index.js @@ -6,6 +6,10 @@ import { type GlobalState } from '~/store/index' import { SAFE_PARAM_ADDRESS, SAFELIST_ADDRESS } from '~/routes/routes' import { type Safe } from '~/routes/safe/store/models/safe' import { type State as TransactionsState, TRANSACTIONS_REDUCER_ID } from '~/routes/safe/store/reducer/transactions' +import { + type CancelState as CancelTransactionsState, + CANCELLATION_TRANSACTIONS_REDUCER_ID, +} from '~/routes/safe/store/reducer/cancellationTransactions' import { type IncomingState as IncomingTransactionsState, INCOMING_TRANSACTIONS_REDUCER_ID, @@ -48,13 +52,21 @@ export const defaultSafeSelector: Selector = createSele const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID] -const incomingTransactionsSelector = (state: GlobalState): IncomingTransactionsState => state[INCOMING_TRANSACTIONS_REDUCER_ID] +const cancellationTransactionsSelector = (state: GlobalState): CancelTransactionsState => state[ + CANCELLATION_TRANSACTIONS_REDUCER_ID +] + +const incomingTransactionsSelector = (state: GlobalState): IncomingTransactionsState => state[ + INCOMING_TRANSACTIONS_REDUCER_ID +] const oneTransactionSelector = (state: GlobalState, props: TransactionProps) => props.transaction export const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || '' -export const safeTransactionsSelector: Selector> = createSelector( +type TxSelectorType = Selector> + +export const safeTransactionsSelector: TxSelectorType = createSelector( transactionsSelector, safeParamAddressSelector, (transactions: TransactionsState, address: string): List => { @@ -80,6 +92,22 @@ export const addressBookQueryParamsSelector = (state: GlobalState): string => { return entryAddressToEditOrCreateNew } +export const safeCancellationTransactionsSelector: TxSelectorType = createSelector( + cancellationTransactionsSelector, + safeParamAddressSelector, + (cancellationTransactions: TransactionsState, address: string): List => { + if (!cancellationTransactions) { + return List([]) + } + + if (!address) { + return List([]) + } + + return cancellationTransactions.get(address) || List([]) + }, +) + export const safeParamAddressFromStateSelector = (state: GlobalState): string => { const match = matchPath( state.router.location.pathname, @@ -89,7 +117,9 @@ export const safeParamAddressFromStateSelector = (state: GlobalState): string => return match ? match.params.safeAddress : null } -export const safeIncomingTransactionsSelector: Selector> = createSelector( +type IncomingTxSelectorType = Selector> + +export const safeIncomingTransactionsSelector: IncomingTxSelectorType = createSelector( incomingTransactionsSelector, safeParamAddressSelector, (incomingTransactions: IncomingTransactionsState, address: string): List => { diff --git a/src/store/index.js b/src/store/index.js index 85b05395..daa148a9 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,7 +2,7 @@ import { createBrowserHistory } from 'history' import { connectRouter, routerMiddleware } from 'connected-react-router' import { - combineReducers, createStore, applyMiddleware, compose, type Reducer, type Store, + combineReducers, createStore, applyMiddleware, compose, type CombinedReducer, type Store, } from 'redux' import thunk from 'redux-thunk' import safe, { SAFE_REDUCER_ID, type SafeReducerState as SafeState } from '~/routes/safe/store/reducer/safe' @@ -12,6 +12,10 @@ import transactions, { type State as TransactionsState, TRANSACTIONS_REDUCER_ID, } from '~/routes/safe/store/reducer/transactions' +import cancellationTransactions, { + type CancelState as CancelTransactionsState, + CANCELLATION_TRANSACTIONS_REDUCER_ID, +} from '~/routes/safe/store/reducer/cancellationTransactions' import incomingTransactions, { type IncomingState as IncomingTransactionsState, INCOMING_TRANSACTIONS_REDUCER_ID, @@ -36,15 +40,21 @@ export const history = createBrowserHistory() // eslint-disable-next-line const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose -const finalCreateStore = composeEnhancers( - applyMiddleware(thunk, routerMiddleware(history), safeStorage, providerWatcher, notificationsMiddleware, addressBookMiddleware), -) +const finalCreateStore = composeEnhancers(applyMiddleware( + thunk, + routerMiddleware(history), + safeStorage, + providerWatcher, + notificationsMiddleware, + addressBookMiddleware, +)) export type GlobalState = { providers: ProviderState, safes: SafeState, tokens: TokensState, transactions: TransactionsState, + cancellationTransactions: CancelTransactionsState, incomingTransactions: IncomingTransactionsState, notifications: NotificationsState, currentSession: CurrentSessionState, @@ -52,12 +62,13 @@ export type GlobalState = { export type GetState = () => GlobalState -const reducers: Reducer = combineReducers({ +const reducers: CombinedReducer = combineReducers({ router: connectRouter(history), [PROVIDER_REDUCER_ID]: provider, [SAFE_REDUCER_ID]: safe, [TOKEN_REDUCER_ID]: tokens, [TRANSACTIONS_REDUCER_ID]: transactions, + [CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions, [INCOMING_TRANSACTIONS_REDUCER_ID]: incomingTransactions, [NOTIFICATIONS_REDUCER_ID]: notifications, [CURRENCY_VALUES_KEY]: currencyValues, @@ -66,7 +77,10 @@ const reducers: Reducer = combineReducers({ [CURRENT_SESSION_REDUCER_ID]: currentSession, }) -export const store: Store = createStore(reducers, finalCreateStore) +export const store: Store = createStore(reducers, finalCreateStore) -// eslint-disable-next-line max-len -export const aNewStore = (localState?: Object): Store => createStore(reducers, localState, finalCreateStore) +export const aNewStore = (localState?: Object): Store => createStore( + reducers, + localState, + finalCreateStore, +)