Feature #406: Transaction Cancellation (#451)

* Add `cancel transactions` state to the store

* Populate store with `cancelTransactions` separated from `transactions`

- also fixed some related typing errors/warnings

* Avoid flow typing errors

* WiP - Merge Tx with its cancellation

* Prevent notification of pending transaction

* Mark transaction as cancelled

* Fix check errors

* Code cleanup

* Use `cancel` flags for confirm/execute of a cancelling tx modal

* Fix `Cancel Tx` button row display conditions

* Fix transaction row condition for cancelled tx

* Fix execute icon display conditions

* Fix condition to display `Confirm Tx` buttons for cancel tx

* Fix conditions for Execute icon

* Add test for `getTxTableData`

* Fix `updateAddressBookEntry` to make tests run

* (Feature) Transaction cancellation: implement design (#465)

* (fix) button icons positions

* (add) new buttons layout

* (fix) executor block style

* (remove) unused file

* (fix) transaction timeline styles

* (fix) overflowing left contents

* Show buttons only in the confirmation thread

* Green line on top of Cancel flow only when tx is executed

* Avoid checking `INCOMING_TX_TYPE` for a cancelTx

* Clean up code conditions

* Rename `cancelTransactions` to `cancellationTransactions`

* Start functions with a verb. `get` in this case.

Co-authored-by: Fernando <fernando.greco@gmail.com>

* Change notification message for cancelling txs

- Added `TX_CANCELLATION_EXECUTED_MSG`
- Also did some flow typing error fixes

* missing file for cancelling tx message

* Always display Reject flow

- display buttons independently (reject buttons in reject flow, confirmation buttons in confirmation flow)

* Reject a Tx when threshold is reached

* Use `safeGasPrice` instead of `gasPrice` to prevent sending `null`s to web3

* Revert "Use `safeGasPrice` instead of `gasPrice` to prevent sending `null`s to web3"

This reverts commit db4cd728

* Revert "Use `safeGasPrice` instead of `gasPrice` to prevent sending `null`s to web3"

This reverts commit db4cd728

Also sets '0' as a default value if `gasPrice` is falsy

* Do not use current `threshold` for closed/executed transactions

* Add closing square bracket

* Verify if txNonce is invalid, zero is a falsy value but a valid nonce

* Display Execute Reject for those tx that met the threshold but weren't able to be executed

* Show pending txs messages for non executed rejections

* wrap `getTimelineCircle` into useMemo

* Remove unnecessary comments

* Verify tx nonce by using `Number.isInteger`

* Parse tx nonce before verifying it

Co-authored-by: Gabriel Rodríguez Alsina <gabitoesmiapodo@users.noreply.github.com>
This commit is contained in:
Fernando 2020-02-04 10:54:59 -03:00 committed by GitHub
parent 226b525c7e
commit 3e5d4f6646
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 745 additions and 365 deletions

View File

@ -2,6 +2,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import Tooltip from '@material-ui/core/Tooltip' import Tooltip from '@material-ui/core/Tooltip'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
import { xs } from '~/theme/variables' import { xs } from '~/theme/variables'
@ -26,11 +27,12 @@ const useStyles = makeStyles({
}) })
type CopyBtnProps = { type CopyBtnProps = {
className?: any,
content: string, content: string,
increaseZindex?: boolean, increaseZindex?: boolean,
} }
const CopyBtn = ({ content, increaseZindex = false }: CopyBtnProps) => { const CopyBtn = ({ className, content, increaseZindex = false }: CopyBtnProps) => {
const [clicked, setClicked] = useState<boolean>(false) const [clicked, setClicked] = useState<boolean>(false)
const classes = useStyles() const classes = useStyles()
const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {} const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {}
@ -50,7 +52,7 @@ const CopyBtn = ({ content, increaseZindex = false }: CopyBtnProps) => {
}} }}
classes={customClasses} classes={customClasses}
> >
<div className={classes.container}> <div className={cn(classes.container, className)}>
<Img <Img
src={CopyIcon} src={CopyIcon}
height={20} height={20}

View File

@ -2,6 +2,7 @@
import React from 'react' import React from 'react'
import Tooltip from '@material-ui/core/Tooltip' import Tooltip from '@material-ui/core/Tooltip'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import EtherscanOpenIcon from './img/etherscan-open.svg' import EtherscanOpenIcon from './img/etherscan-open.svg'
import { getEtherScanLink } from '~/logic/wallets/getWeb3' import { getEtherScanLink } from '~/logic/wallets/getWeb3'
@ -26,12 +27,15 @@ const useStyles = makeStyles({
}) })
type EtherscanBtnProps = { type EtherscanBtnProps = {
className?: any,
increaseZindex?: boolean,
type: 'tx' | 'address', type: 'tx' | 'address',
value: string, value: string,
increaseZindex?: boolean,
} }
const EtherscanBtn = ({ type, value, increaseZindex = false }: EtherscanBtnProps) => { const EtherscanBtn = ({
type, value, className, increaseZindex = false,
}: EtherscanBtnProps) => {
const classes = useStyles() const classes = useStyles()
const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {} const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {}
@ -39,7 +43,7 @@ const EtherscanBtn = ({ type, value, increaseZindex = false }: EtherscanBtnProps
<Tooltip title="Show details on Etherscan" placement="top" classes={customClasses}> <Tooltip title="Show details on Etherscan" placement="top" classes={customClasses}>
<a <a
aria-label="Show details on Etherscan" aria-label="Show details on Etherscan"
className={classes.container} className={cn(classes.container, className)}
href={getEtherScanLink(type, value)} href={getEtherScanLink(type, value)}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"

View File

@ -11,11 +11,11 @@ import EllipsisTransactionDetails from '~/routes/safe/components/AddressBook/Ell
import Span from '~/components/layout/Span' import Span from '~/components/layout/Span'
type EtherscanLinkProps = { type EtherscanLinkProps = {
type: 'tx' | 'address', classes: Object,
value: string,
cut?: number, cut?: number,
knownAddress?: boolean, knownAddress?: boolean,
classes: Object, type: 'tx' | 'address',
value: string,
} }
const EtherscanLink = ({ const EtherscanLink = ({
@ -23,13 +23,13 @@ const EtherscanLink = ({
}: EtherscanLinkProps) => ( }: EtherscanLinkProps) => (
<Block className={classes.etherscanLink}> <Block className={classes.etherscanLink}>
<Span <Span
className={cn(knownAddress && classes.addressParagraph, classes.address)}
size="md" size="md"
className={cn(knownAddress && classes.addressParagraph)}
> >
{cut ? shortVersionOf(value, cut) : value} {cut ? shortVersionOf(value, cut) : value}
</Span> </Span>
<CopyBtn content={value} /> <CopyBtn className={cn(classes.button, classes.firstButton)} content={value} />
<EtherscanBtn type={type} value={value} /> <EtherscanBtn className={classes.button} type={type} value={value} />
{knownAddress !== undefined ? <EllipsisTransactionDetails knownAddress={knownAddress} address={value} /> : null} {knownAddress !== undefined ? <EllipsisTransactionDetails knownAddress={knownAddress} address={value} /> : null}
</Block> </Block>
) )

View File

@ -1,11 +1,30 @@
// @flow // @flow
import { secondaryText } from '~/theme/variables'
export const styles = () => ({ export const styles = () => ({
etherscanLink: { etherscanLink: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
'& svg': {
fill: secondaryText,
},
},
address: {
display: 'block',
flexShrink: '1',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}, },
addressParagraph: { addressParagraph: {
fontSize: '13px', fontSize: '13px',
}, },
button: {
height: '24px',
margin: '0',
width: '24px',
},
firstButton: {
marginLeft: '8px',
},
}) })

View File

@ -2,12 +2,12 @@
import type { Dispatch as ReduxDispatch } from 'redux' import type { Dispatch as ReduxDispatch } from 'redux'
import { type GlobalState } from '~/store/index' import { type GlobalState } from '~/store/index'
import { saveAddressBook } from '~/logic/addressBook/utils' 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' import type { AddressBook } from '~/logic/addressBook/model/addressBook'
const saveAndUpdateAddressBook = (addressBook: AddressBook) => async (dispatch: ReduxDispatch<GlobalState>) => { const saveAndUpdateAddressBook = (addressBook: AddressBook) => async (dispatch: ReduxDispatch<GlobalState>) => {
try { try {
dispatch(updateAddressBook(addressBook)) dispatch(updateAddressBookEntry(addressBook))
await saveAddressBook(addressBook) await saveAddressBook(addressBook)
} catch (err) { } catch (err) {
// eslint-disable-next-line // eslint-disable-next-line

View File

@ -10,11 +10,11 @@ import { type Notification, NOTIFICATIONS } from './notificationTypes'
export type NotificationsQueue = { export type NotificationsQueue = {
beforeExecution: Notification | null, beforeExecution: Notification | null,
pendingExecution: Notification | null, pendingExecution: Notification | null,
waitingConfirmation: Notification | null, waitingConfirmation?: Notification | null,
afterExecution: { afterExecution: {
noMoreConfirmationsNeeded: Notification | null, noMoreConfirmationsNeeded: Notification | null,
moreConfirmationsNeeded: Notification | null, moreConfirmationsNeeded: Notification | null,
}, } | null,
afterExecutionError: Notification | null, afterExecutionError: Notification | null,
afterRejection: Notification | null, afterRejection: Notification | null,
} }
@ -56,7 +56,7 @@ const cancellationTxNotificationsQueue: NotificationsQueue = {
afterRejection: NOTIFICATIONS.TX_REJECTED_MSG, afterRejection: NOTIFICATIONS.TX_REJECTED_MSG,
afterExecution: { afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.TX_EXECUTED_MSG, noMoreConfirmationsNeeded: NOTIFICATIONS.TX_EXECUTED_MSG,
moreConfirmationsNeeded: NOTIFICATIONS.TX_EXECUTED_MORE_CONFIRMATIONS_MSG, moreConfirmationsNeeded: NOTIFICATIONS.TX_CANCELLATION_EXECUTED_MSG,
}, },
afterExecutionError: NOTIFICATIONS.TX_FAILED_MSG, afterExecutionError: NOTIFICATIONS.TX_FAILED_MSG,
} }
@ -200,19 +200,26 @@ export const enhanceSnackbarForAction = (notification: Notification, key?: strin
options: { options: {
...notification.options, ...notification.options,
onClick, onClick,
action: (key: number) => ( action: (actionKey: number) => (
<IconButton onClick={() => store.dispatch(closeSnackbarAction({ key }))}> <IconButton onClick={() => store.dispatch(closeSnackbarAction({ key: actionKey }))}>
<IconClose /> <IconClose />
</IconButton> </IconButton>
), ),
}, },
}) })
export const showSnackbar = (notification: Notification, enqueueSnackbar: Function, closeSnackbar: Function) => enqueueSnackbar(notification.message, { export const showSnackbar = (
notification: Notification,
enqueueSnackbar: Function,
closeSnackbar: Function,
) => enqueueSnackbar(
notification.message,
{
...notification.options, ...notification.options,
action: (key) => ( action: (key) => (
<IconButton onClick={() => closeSnackbar(key)}> <IconButton onClick={() => closeSnackbar(key)}>
<IconClose /> <IconClose />
</IconButton> </IconButton>
), ),
}) },
)

View File

@ -37,6 +37,7 @@ export type Notifications = {
TX_PENDING_MSG: Notification, TX_PENDING_MSG: Notification,
TX_REJECTED_MSG: Notification, TX_REJECTED_MSG: Notification,
TX_EXECUTED_MSG: Notification, TX_EXECUTED_MSG: Notification,
TX_CANCELLATION_EXECUTED_MSG: Notification,
TX_EXECUTED_MORE_CONFIRMATIONS_MSG: Notification, TX_EXECUTED_MORE_CONFIRMATIONS_MSG: Notification,
TX_FAILED_MSG: Notification, TX_FAILED_MSG: Notification,
TX_WAITING_MSG: Notification, TX_WAITING_MSG: Notification,
@ -57,6 +58,7 @@ export type Notifications = {
SIGN_SETTINGS_CHANGE_MSG: Notification, SIGN_SETTINGS_CHANGE_MSG: Notification,
SETTINGS_CHANGE_PENDING_MSG: Notification, SETTINGS_CHANGE_PENDING_MSG: Notification,
SETTINGS_CHANGE_REJECTED_MSG: Notification, SETTINGS_CHANGE_REJECTED_MSG: Notification,
SETTINGS_CHANGE_EXECUTED_MSG: Notification,
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: Notification, SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: Notification,
SETTINGS_CHANGE_FAILED_MSG: Notification, SETTINGS_CHANGE_FAILED_MSG: Notification,
@ -126,6 +128,10 @@ export const NOTIFICATIONS: Notifications = {
message: 'Transaction successfully executed', message: 'Transaction successfully executed',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration }, 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: { TX_EXECUTED_MORE_CONFIRMATIONS_MSG: {
message: 'Transaction successfully created. More confirmations needed to execute', message: 'Transaction successfully created. More confirmations needed to execute',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration }, options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },

View File

@ -4,6 +4,7 @@ import type { Transaction } from '~/routes/safe/store/models/transaction'
export const getAwaitingTransactions = ( export const getAwaitingTransactions = (
allTransactions: Map<string, List<Transaction>>, allTransactions: Map<string, List<Transaction>>,
cancellationTransactionsByNonce: Map<string | number, List<Transaction>>,
userAccount: string, userAccount: string,
): Map<string, List<Transaction>> => { ): Map<string, List<Transaction>> => {
if (!allTransactions) { 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 // 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 // it means that the transaction was cancelled (Replaced) and shouldn't get executed
if (!transaction.isExecuted) { if (!transaction.isExecuted) {
const replacementTransaction = safeTransactions.findLast( if (cancellationTransactionsByNonce.get(transaction.nonce)) {
(tx) => tx.isExecuted && tx.nonce === transaction.nonce,
)
if (replacementTransaction) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
transaction = transaction.set('cancelled', true) transaction = transaction.set('cancelled', true)
} }
} }
// The transaction is not executed and is not cancelled, so it's still waiting confirmations // The transaction is not executed and is not cancelled, so it's still waiting confirmations
if (!transaction.executionTxHash && !transaction.cancelled) { 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( const transactionWaitingUser = transaction.confirmations.filter(
(confirmation) => confirmation.owner && confirmation.owner.address !== userAccount, (confirmation) => confirmation.owner && confirmation.owner.address !== userAccount,
) )

View File

@ -9,7 +9,6 @@ export type NotifiedTransaction = {
SAFE_NAME_CHANGE_TX: string, SAFE_NAME_CHANGE_TX: string,
OWNER_NAME_CHANGE_TX: string, OWNER_NAME_CHANGE_TX: string,
ADDRESSBOOK_NEW_ENTRY: string, ADDRESSBOOK_NEW_ENTRY: string,
ADDRESSBOOK_EDIT_ENTRY: string,
ADDRESSBOOK_DELETE_ENTRY: string, ADDRESSBOOK_DELETE_ENTRY: string,
} }

View File

@ -49,4 +49,4 @@ export const isAddressAToken = async (tokenAddress: string) => {
return call !== '0x' 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

View File

@ -71,6 +71,7 @@ const Layout = (props: Props) => {
fetchTokens, fetchTokens,
updateSafe, updateSafe,
transactions, transactions,
cancellationTransactions,
userAddress, userAddress,
sendFunds, sendFunds,
showReceive, showReceive,
@ -190,6 +191,7 @@ const Layout = (props: Props) => {
owners={safe.owners} owners={safe.owners}
nonce={safe.nonce} nonce={safe.nonce}
transactions={transactions} transactions={transactions}
cancellationTransactions={cancellationTransactions}
safeAddress={address} safeAddress={address}
userAddress={userAddress} userAddress={userAddress}
currentNetwork={network} currentNetwork={network}

View File

@ -20,7 +20,7 @@ const OwnerAddressTableCell = (props: Props) => {
<Block justify="left"> <Block justify="left">
<Identicon address={address} diameter={32} /> <Identicon address={address} diameter={32} />
{ showLinks ? ( { showLinks ? (
<div style={{ marginLeft: 10 }}> <div style={{ marginLeft: 10, flexShrink: 1, minWidth: 0 }}>
{ userName } { userName }
<EtherScanLink type="address" value={address} knownAddress={knownAddress} /> <EtherScanLink type="address" value={address} knownAddress={knownAddress} />
</div> </div>

View File

@ -21,11 +21,13 @@ import { type Transaction } from '~/routes/safe/store/models/transaction'
import { styles } from './style' import { styles } from './style'
export const APPROVE_TX_MODAL_SUBMIT_BTN_TEST_ID = 'approve-tx-modal-submit-btn' 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 = { type Props = {
onClose: () => void, onClose: () => void,
classes: Object, classes: Object,
isOpen: boolean, isOpen: boolean,
isCancelTx?: boolean,
processTransaction: Function, processTransaction: Function,
tx: Transaction, tx: Transaction,
nonce: string, nonce: string,
@ -38,23 +40,31 @@ type Props = {
closeSnackbar: Function closeSnackbar: Function
} }
const getModalTitleAndDescription = (thresholdReached: boolean) => { const getModalTitleAndDescription = (thresholdReached: boolean, isCancelTx?: boolean) => {
const title = thresholdReached ? 'Execute Transaction' : 'Approve Transaction' const modalInfo = {
const description = `This action will ${ title: 'Execute Transaction Rejection',
thresholdReached ? 'execute' : 'approve' description: 'This action will execute this transaction.',
} this transaction. A separate transaction will be performed to submit the ${
thresholdReached ? 'execution' : 'approval'
}.`
return {
title,
description,
} }
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 = ({ const ApproveTxModal = ({
onClose, onClose,
isOpen, isOpen,
isCancelTx,
classes, classes,
processTransaction, processTransaction,
tx, tx,
@ -68,7 +78,7 @@ const ApproveTxModal = ({
}: Props) => { }: Props) => {
const [approveAndExecute, setApproveAndExecute] = useState<boolean>(canExecute) const [approveAndExecute, setApproveAndExecute] = useState<boolean>(canExecute)
const [gasCosts, setGasCosts] = useState<string>('< 0.001') const [gasCosts, setGasCosts] = useState<string>('< 0.001')
const { title, description } = getModalTitleAndDescription(thresholdReached) const { title, description } = getModalTitleAndDescription(thresholdReached, isCancelTx)
const oneConfirmationLeft = !thresholdReached && tx.confirmations.size + 1 === threshold const oneConfirmationLeft = !thresholdReached && tx.confirmations.size + 1 === threshold
const isTheTxReadyToBeExecuted = oneConfirmationLeft ? true : thresholdReached const isTheTxReadyToBeExecuted = oneConfirmationLeft ? true : thresholdReached
@ -132,7 +142,7 @@ const ApproveTxModal = ({
</Row> </Row>
<Hairline /> <Hairline />
<Block className={classes.container}> <Block className={classes.container}>
<Row> <Row style={{ flexDirection: 'column' }}>
<Paragraph>{description}</Paragraph> <Paragraph>{description}</Paragraph>
<Paragraph size="sm" color="medium"> <Paragraph size="sm" color="medium">
Transaction nonce: Transaction nonce:
@ -142,10 +152,11 @@ const ApproveTxModal = ({
{oneConfirmationLeft && canExecute && ( {oneConfirmationLeft && canExecute && (
<> <>
<Paragraph color="error"> <Paragraph color="error">
Approving this transaction executes it right away. If you want Approving this transaction executes it right away.
approve but execute the transaction manually later, click on the {!isCancelTx && ' If you want approve but execute the transaction manually later, click on the '
checkbox below. + 'checkbox below.'}
</Paragraph> </Paragraph>
{!isCancelTx && (
<FormControlLabel <FormControlLabel
control={( control={(
<Checkbox <Checkbox
@ -156,6 +167,7 @@ const ApproveTxModal = ({
)} )}
label="Execute transaction" label="Execute transaction"
/> />
)}
</> </>
)} )}
</Row> </Row>
@ -176,9 +188,9 @@ const ApproveTxModal = ({
variant="contained" variant="contained"
minWidth={214} minWidth={214}
minHeight={42} minHeight={42}
color="primary" color={isCancelTx ? 'secondary' : 'primary'}
onClick={approveTx} 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} {title}
</Button> </Button>

View File

@ -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) => (
<Row align="end" className={classes.buttonRow}>
{showCancelBtn && (
<Button className={classes.button} variant="contained" minWidth={140} color="secondary" onClick={onTxCancel}>
Cancel tx
</Button>
)}
</Row>
)
export default withStyles(styles)(ButtonRow)

View File

@ -1,6 +1,7 @@
// @flow // @flow
import React from 'react' import React from 'react'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import Button from '~/components/layout/Button' import Button from '~/components/layout/Button'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import EtherscanLink from '~/components/EtherscanLink' import EtherscanLink from '~/components/EtherscanLink'
@ -9,58 +10,70 @@ import Block from '~/components/layout/Block'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import { type Owner } from '~/routes/safe/store/models/owner' import { type Owner } from '~/routes/safe/store/models/owner'
import { styles } from './style' import { styles } from './style'
import ConfirmSmallGreyIcon from './assets/confirm-small-grey.svg' import ConfirmSmallGreyCircle from './assets/confirm-small-grey.svg'
import ConfirmSmallGreenIcon from './assets/confirm-small-green.svg' import ConfirmSmallGreenCircle from './assets/confirm-small-green.svg'
import ConfirmSmallFilledIcon from './assets/confirm-small-filled.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' import { getNameFromAddressBook } from '~/logic/addressBook/utils'
export const CONFIRM_TX_BTN_TEST_ID = 'confirm-btn' export const CONFIRM_TX_BTN_TEST_ID = 'confirm-btn'
export const EXECUTE_TX_BTN_TEST_ID = 'execute-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 = { type OwnerProps = {
owner: Owner,
classes: Object, classes: Object,
userAddress: string,
confirmed?: boolean, confirmed?: boolean,
executor?: string, executor?: string,
thresholdReached: boolean, isCancelTx?: boolean,
onTxReject?: Function,
onTxConfirm: Function,
onTxExecute: Function,
owner: Owner,
showRejectBtn: boolean,
showExecuteRejectBtn: boolean,
showConfirmBtn: boolean, showConfirmBtn: boolean,
showExecuteBtn: boolean, showExecuteBtn: boolean,
onTxConfirm: Function, thresholdReached: boolean,
onTxExecute: Function userAddress: string,
} }
const OwnerComponent = ({ const OwnerComponent = ({
owner, onTxReject,
userAddress,
classes, classes,
confirmed,
executor,
isCancelTx,
onTxConfirm, onTxConfirm,
onTxExecute,
owner,
showRejectBtn,
showExecuteRejectBtn,
showConfirmBtn, showConfirmBtn,
showExecuteBtn, showExecuteBtn,
onTxExecute,
executor,
confirmed,
thresholdReached, thresholdReached,
userAddress,
}: OwnerProps) => { }: OwnerProps) => {
const nameInAdbk = getNameFromAddressBook(owner.address) const nameInAdbk = getNameFromAddressBook(owner.address)
const ownerName = nameInAdbk || owner.name 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 ( return (
<Block className={classes.container}> <Block className={classes.container}>
<div <div className={cn(classes.verticalLine, (confirmed || thresholdReached || executor) && getTimelineLine())} />
className={ <div className={classes.circleState}>
confirmed || thresholdReached || executor <Img src={imgCircle} alt="" />
? classes.verticalLineProgressDone
: classes.verticalLineProgressPending
}
/>
<div className={classes.iconState}>
{confirmed ? (
<Img src={ConfirmSmallFilledIcon} />
) : thresholdReached || executor ? (
<Img src={ConfirmSmallGreenIcon} />
) : (
<Img src={ConfirmSmallGreyIcon} />
)}
</div> </div>
<Identicon address={owner.address} diameter={32} className={classes.icon} /> <Identicon address={owner.address} diameter={32} className={classes.icon} />
<Block> <Block>
@ -75,30 +88,61 @@ const OwnerComponent = ({
/> />
</Block> </Block>
<Block className={classes.spacer} /> <Block className={classes.spacer} />
{showConfirmBtn && owner.address === userAddress && ( {owner.address === userAddress && (
<Block>
{isCancelTx ? (
<>
{showRejectBtn && (
<Button
className={cn(classes.button, classes.lastButton)}
color="secondary"
onClick={onTxReject}
testId={REJECT_TX_BTN_TEST_ID}
variant="contained"
>
Reject
</Button>
)}
{showExecuteRejectBtn && (
<Button
className={cn(classes.button, classes.lastButton)}
color="secondary"
onClick={onTxReject}
testId={EXECUTE_REJECT_TX_BTN_TEST_ID}
variant="contained"
>
Execute
</Button>
)}
</>
) : (
<>
{showConfirmBtn && (
<Button <Button
className={classes.button} className={classes.button}
variant="contained"
minWidth={140}
color="primary" color="primary"
onClick={onTxConfirm} onClick={onTxConfirm}
testId={CONFIRM_TX_BTN_TEST_ID} testId={CONFIRM_TX_BTN_TEST_ID}
variant="contained"
> >
Confirm tx Confirm
</Button> </Button>
)} )}
{showExecuteBtn && owner.address === userAddress && ( {showExecuteBtn && (
<Button <Button
className={classes.button} className={classes.button}
variant="contained"
minWidth={140}
color="primary" color="primary"
onClick={onTxExecute} onClick={onTxExecute}
testId={EXECUTE_TX_BTN_TEST_ID} testId={EXECUTE_TX_BTN_TEST_ID}
variant="contained"
> >
Execute tx Execute
</Button> </Button>
)} )}
</>
)}
</Block>
)}
{owner.address === executor && ( {owner.address === executor && (
<Block className={classes.executor}>Executor</Block> <Block className={classes.executor}>Executor</Block>
)} )}

View File

@ -7,56 +7,72 @@ import { type Owner } from '~/routes/safe/store/models/owner'
import { styles } from './style' import { styles } from './style'
type ListProps = { type ListProps = {
ownersWhoConfirmed: List<Owner>,
ownersUnconfirmed: List<Owner>,
classes: Object, classes: Object,
userAddress: string,
executor: string, executor: string,
thresholdReached: boolean, isCancelTx?: boolean,
showConfirmBtn: boolean, onTxReject?: Function,
showExecuteBtn: boolean,
onTxConfirm: Function, onTxConfirm: Function,
onTxExecute: Function, onTxExecute: Function,
ownersUnconfirmed: List<Owner>,
ownersWhoConfirmed: List<Owner>,
showRejectBtn: boolean,
showExecuteRejectBtn: boolean,
showConfirmBtn: boolean,
showExecuteBtn: boolean,
thresholdReached: boolean,
userAddress: string,
} }
const OwnersList = ({ const OwnersList = ({
userAddress,
ownersWhoConfirmed,
ownersUnconfirmed,
classes, classes,
executor, executor,
thresholdReached, isCancelTx,
showConfirmBtn, onTxReject,
showExecuteBtn,
onTxConfirm, onTxConfirm,
onTxExecute, onTxExecute,
ownersUnconfirmed,
ownersWhoConfirmed,
showRejectBtn,
showExecuteRejectBtn,
showConfirmBtn,
showExecuteBtn,
thresholdReached,
userAddress,
}: ListProps) => ( }: ListProps) => (
<> <>
{ownersWhoConfirmed.map((owner) => ( {ownersWhoConfirmed.map((owner) => (
<OwnerComponent <OwnerComponent
key={owner.address}
owner={owner}
classes={classes} classes={classes}
userAddress={userAddress}
executor={executor}
thresholdReached={thresholdReached}
confirmed confirmed
showExecuteBtn={showExecuteBtn} executor={executor}
isCancelTx={isCancelTx}
key={owner.address}
onTxReject={onTxReject}
onTxExecute={onTxExecute} onTxExecute={onTxExecute}
owner={owner}
showRejectBtn={showRejectBtn}
showExecuteRejectBtn={showExecuteRejectBtn}
showExecuteBtn={showExecuteBtn}
thresholdReached={thresholdReached}
userAddress={userAddress}
/> />
))} ))}
{ownersUnconfirmed.map((owner) => ( {ownersUnconfirmed.map((owner) => (
<OwnerComponent <OwnerComponent
key={owner.address}
owner={owner}
classes={classes} classes={classes}
userAddress={userAddress}
executor={executor} executor={executor}
thresholdReached={thresholdReached} isCancelTx={isCancelTx}
key={owner.address}
onTxReject={onTxReject}
onTxConfirm={onTxConfirm} onTxConfirm={onTxConfirm}
onTxExecute={onTxExecute}
owner={owner}
showRejectBtn={showRejectBtn}
showExecuteRejectBtn={showExecuteRejectBtn}
showConfirmBtn={showConfirmBtn} showConfirmBtn={showConfirmBtn}
showExecuteBtn={showExecuteBtn} showExecuteBtn={showExecuteBtn}
onTxExecute={onTxExecute} thresholdReached={thresholdReached}
userAddress={userAddress}
/> />
))} ))}
</> </>

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="5" fill="#f02525" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 160 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g fill="none" fill-rule="evenodd">
<path fill="#f02525" d="M10 0C4.489 0 0 4.489 0 10s4.489 10 10 10 10-4.489 10-10S15.511 0 10 0z"/>
<path fill="#FFF" d="M9.124 13.75L5 9.406l1.245-1.312 2.88 3.034 4.63-4.878L15 7.561z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="9" fill="#FFFAF4" fill-rule="evenodd" stroke="#f02525" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="4" fill="#FFFAF4" fill-rule="evenodd" stroke="#f02525" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@ -7,71 +7,111 @@ import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import { type Owner } from '~/routes/safe/store/models/owner' 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 { TX_TYPE_CONFIRMATION } from '~/logic/safe/transactions/send'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import OwnersList from './OwnersList' import OwnersList from './OwnersList'
import ButtonRow from './ButtonRow' import CheckLargeFilledGreenCircle from './assets/check-large-filled-green.svg'
import CheckLargeFilledGreenIcon from './assets/check-large-filled-green.svg' import ConfirmLargeGreenCircle from './assets/confirm-large-green.svg'
import ConfirmLargeGreenIcon from './assets/confirm-large-green.svg' import CheckLargeFilledRedCircle from './assets/check-large-filled-red.svg'
import ConfirmLargeGreyIcon from './assets/confirm-large-grey.svg' import ConfirmLargeRedCircle from './assets/confirm-large-red.svg'
import ConfirmLargeGreyCircle from './assets/confirm-large-grey.svg'
import { styles } from './style' import { styles } from './style'
import Paragraph from '~/components/layout/Paragraph/index' import Paragraph from '~/components/layout/Paragraph/index'
type Props = { type Props = {
tx: Transaction, canExecute: boolean,
owners: List<Owner>, canExecuteCancel: boolean,
cancelThresholdReached: boolean,
cancelTx: Transaction,
classes: Object, classes: Object,
granted: boolean, granted: boolean,
threshold: number, onTxReject: Function,
userAddress: string,
thresholdReached: boolean,
safeAddress: string,
canExecute: boolean,
onTxConfirm: Function, onTxConfirm: Function,
onTxCancel: Function, onTxExecute: Function,
onTxExecute: Function owners: List<Owner>,
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
} }
const isCancellationTransaction = (tx: Transaction, safeAddress: string) => !tx.value && tx.data === EMPTY_DATA && tx.recipient === safeAddress if (conf.type === TX_TYPE_CONFIRMATION) {
ownersWhoConfirmed.push(conf.owner)
}
})
return [ownersWhoConfirmed, currentUserAlreadyConfirmed]
}
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 = ({ const OwnersColumn = ({
tx, tx,
cancelTx = makeTransaction(),
owners, owners,
classes, classes,
granted, granted,
threshold, threshold,
userAddress, userAddress,
thresholdReached, thresholdReached,
safeAddress, cancelThresholdReached,
onTxConfirm, onTxConfirm,
onTxCancel,
onTxExecute, onTxExecute,
onTxReject,
canExecute, canExecute,
canExecuteCancel,
}: Props) => { }: Props) => {
const cancellationTx = isCancellationTransaction(tx, safeAddress) let showOlderTxAnnotation: boolean
const showOlderTxAnnotation = thresholdReached && !canExecute && !tx.isExecuted
const ownersWhoConfirmed = [] if (tx.isExecuted || cancelTx.isExecuted) {
let currentUserAlreadyConfirmed = false showOlderTxAnnotation = false
tx.confirmations.forEach((conf) => { } else {
if (conf.owner.address === userAddress) { showOlderTxAnnotation = (thresholdReached && !canExecute) || (cancelThresholdReached && !canExecuteCancel)
currentUserAlreadyConfirmed = true
} }
if (conf.type === TX_TYPE_CONFIRMATION) {
ownersWhoConfirmed.push(conf.owner) const [
} ownersWhoConfirmed,
}) currentUserAlreadyConfirmed,
const ownersUnconfirmed = owners.filter( ] = getOwnersConfirmations(tx, userAddress)
(owner) => tx.confirmations.findIndex( const [
(conf) => conf.owner.address === owner.address, ownersUnconfirmed,
) === -1, userIsUnconfirmedOwner,
) ] = getPendingOwnersConfirmations(owners, tx, userAddress)
let userIsUnconfirmedOwner const [
ownersUnconfirmed.some((owner) => { ownersWhoConfirmedCancel,
userIsUnconfirmedOwner = owner.address === userAddress currentUserAlreadyConfirmedCancel,
return userIsUnconfirmedOwner ] = getOwnersConfirmations(cancelTx, userAddress)
}) const [
ownersUnconfirmedCancel,
userIsUnconfirmedCancelOwner,
] = getPendingOwnersConfirmations(owners, cancelTx, userAddress)
let displayButtonRow = true let displayButtonRow = true
if (tx.executionTxHash) { if (tx.executionTxHash) {
@ -80,13 +120,7 @@ const OwnersColumn = ({
} else if (tx.status === 'cancelled') { } else if (tx.status === 'cancelled') {
// tx is cancelled (replaced) by another one // tx is cancelled (replaced) by another one
displayButtonRow = false displayButtonRow = false
} else if ( } else if (currentUserAlreadyConfirmedCancel) {
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)
displayButtonRow = false displayButtonRow = false
} }
@ -99,6 +133,19 @@ const OwnersColumn = ({
const showExecuteBtn = canExecute && !tx.isExecuted && thresholdReached 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 ( return (
<Col xs={6} className={classes.rightCol} layout="block"> <Col xs={6} className={classes.rightCol} layout="block">
<Block <Block
@ -107,54 +154,85 @@ const OwnersColumn = ({
(thresholdReached || tx.isExecuted) && classes.ownerListTitleDone, (thresholdReached || tx.isExecuted) && classes.ownerListTitleDone,
)} )}
> >
<div className={classes.iconState}> <div className={classes.circleState}>
{thresholdReached || tx.isExecuted ? ( <Img src={thresholdReached || tx.isExecuted ? CheckLargeFilledGreenCircle : ConfirmLargeGreenCircle} alt="" />
<Img src={CheckLargeFilledGreenIcon} />
) : (
<Img src={ConfirmLargeGreenIcon} />
)}
</div> </div>
{tx.isExecuted {tx.isExecuted
? `Confirmed [${tx.confirmations.size}/${tx.confirmations.size}]` ? `Confirmed [${tx.confirmations.size}/${tx.confirmations.size}]`
: `Confirmed [${tx.confirmations.size}/${threshold}]`} : `Confirmed [${tx.confirmations.size}/${txThreshold}]`}
</Block> </Block>
<OwnersList <OwnersList
userAddress={userAddress}
ownersWhoConfirmed={ownersWhoConfirmed}
ownersUnconfirmed={ownersUnconfirmed}
executor={tx.executor} executor={tx.executor}
thresholdReached={thresholdReached}
onTxConfirm={onTxConfirm} onTxConfirm={onTxConfirm}
onTxExecute={onTxExecute} onTxExecute={onTxExecute}
ownersUnconfirmed={ownersUnconfirmed}
ownersWhoConfirmed={ownersWhoConfirmed}
showConfirmBtn={showConfirmBtn} showConfirmBtn={showConfirmBtn}
showExecuteBtn={showExecuteBtn} showExecuteBtn={showExecuteBtn}
thresholdReached={thresholdReached}
userAddress={userAddress}
/> />
{/* Cancel TX thread - START */}
<Block
className={cn(
classes.ownerListTitle,
(cancelThresholdReached || cancelTx.isExecuted) && classes.ownerListTitleCancelDone,
)}
>
<div
className={cn(
classes.verticalLine,
tx.isExecuted ? classes.verticalLineDone : classes.verticalLinePending,
)}
/>
<div className={classes.circleState}>
<Img src={cancelThresholdReached || cancelTx.isExecuted ? CheckLargeFilledRedCircle : ConfirmLargeRedCircle} alt="" />
</div>
{cancelTx.isExecuted
? `Rejected [${cancelTx.confirmations.size}/${cancelTx.confirmations.size}]`
: `Rejected [${cancelTx.confirmations.size}/${cancelThreshold}]`}
</Block>
<OwnersList
isCancelTx
executor={cancelTx.executor}
onTxReject={onTxReject}
ownersUnconfirmed={ownersUnconfirmedCancel}
ownersWhoConfirmed={ownersWhoConfirmedCancel}
showRejectBtn={showRejectBtn}
showExecuteRejectBtn={showExecuteRejectBtn}
thresholdReached={cancelThresholdReached}
userAddress={userAddress}
/>
{/* Cancel TX thread - END */}
<Block <Block
className={cn( className={cn(
classes.ownerListTitle, classes.ownerListTitle,
tx.isExecuted && classes.ownerListTitleDone, tx.isExecuted && classes.ownerListTitleDone,
cancelTx.isExecuted && classes.ownerListTitleCancelDone,
)} )}
> >
<div <div
className={ className={cn(
thresholdReached || tx.isExecuted classes.verticalLine,
? classes.verticalLineProgressDone tx.isExecuted && classes.verticalLineDone,
: classes.verticalLineProgressPending cancelTx.isExecuted && classes.verticalLineCancel,
}
/>
<div className={classes.iconState}>
{!thresholdReached && !tx.isExecuted && (
<Img src={ConfirmLargeGreyIcon} alt="Confirm tx" />
)} )}
{thresholdReached && !tx.isExecuted && ( />
<Img src={ConfirmLargeGreenIcon} alt="Execute tx" />
<div className={classes.circleState}>
{!tx.isExecuted && !cancelTx.isExecuted && (
<Img src={ConfirmLargeGreyCircle} alt="Confirm / Execute tx" />
)} )}
{tx.isExecuted && ( {tx.isExecuted && (
<Img src={CheckLargeFilledGreenIcon} alt="TX Executed icon" /> <Img src={CheckLargeFilledGreenCircle} alt="TX Executed icon" />
)}
{cancelTx.isExecuted && (
<Img src={CheckLargeFilledRedCircle} alt="TX Executed icon" />
)} )}
</div> </div>
Executed Executed
</Block> </Block>
{showOlderTxAnnotation && ( {showOlderTxAnnotation && (
<Block className={classes.olderTxAnnotation}> <Block className={classes.olderTxAnnotation}>
<Paragraph> <Paragraph>
@ -162,9 +240,6 @@ const OwnersColumn = ({
</Paragraph> </Paragraph>
</Block> </Block>
)} )}
{granted && displayButtonRow && (
<ButtonRow onTxCancel={onTxCancel} showCancelBtn={!cancellationTx} />
)}
</Col> </Col>
) )
} }

View File

@ -1,34 +1,36 @@
// @flow // @flow
import { import {
border, sm, boldFont, primary, secondary, secondaryText, border, sm, boldFont, primary, secondary, secondaryText, error,
} from '~/theme/variables' } from '~/theme/variables'
export const styles = () => ({ export const styles = () => ({
ownersList: { ownersList: {
width: '100%',
padding: 0,
height: '192px', height: '192px',
overflowY: 'scroll', overflowY: 'scroll',
padding: 0,
width: '100%',
}, },
rightCol: { rightCol: {
boxSizing: 'border-box',
borderLeft: `2px solid ${border}`, borderLeft: `2px solid ${border}`,
boxSizing: 'border-box',
}, },
verticalLineProgressPending: { verticalLine: {
backgroundColor: secondaryText,
height: '55px',
left: '27px',
position: 'absolute', position: 'absolute',
borderLeft: `2px solid ${secondaryText}`, top: '-27px',
height: '52px', width: '2px',
top: '-26px',
left: '29px',
zIndex: '10', zIndex: '10',
}, },
verticalLineProgressDone: { verticalLinePending: {
position: 'absolute', backgroundColor: secondaryText,
borderLeft: `2px solid ${secondary}`, },
height: '52px', verticalLineDone: {
top: '-26px', backgroundColor: secondary,
left: '29px', },
zIndex: '10', verticalLineCancel: {
backgroundColor: error,
}, },
icon: { icon: {
marginRight: sm, marginRight: sm,
@ -37,21 +39,21 @@ export const styles = () => ({
borderBottom: `1px solid ${border}`, borderBottom: `1px solid ${border}`,
}, },
container: { container: {
position: 'relative', alignItems: 'center',
display: 'flex', display: 'flex',
padding: '5px 20px', padding: '13px 15px 13px 18px',
position: 'relative',
}, },
ownerListTitle: { ownerListTitle: {
position: 'relative',
display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '15px', display: 'flex',
paddingLeft: '20px',
fontSize: '11px', fontSize: '11px',
fontWeight: boldFont, fontWeight: boldFont,
lineHeight: 1.27,
textTransform: 'uppercase',
letterSpacing: '1px', letterSpacing: '1px',
lineHeight: 1.3,
padding: '15px 15px 15px 18px',
position: 'relative',
textTransform: 'uppercase',
}, },
olderTxAnnotation: { olderTxAnnotation: {
textAlign: 'center', textAlign: 'center',
@ -59,10 +61,13 @@ export const styles = () => ({
ownerListTitleDone: { ownerListTitleDone: {
color: secondary, color: secondary,
}, },
ownerListTitleCancelDone: {
color: error,
},
name: { name: {
textOverflow: 'ellipsis',
overflow: 'hidden',
height: '15px', height: '15px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}, },
address: { address: {
height: '20px', height: '20px',
@ -70,26 +75,37 @@ export const styles = () => ({
spacer: { spacer: {
flex: 'auto', flex: 'auto',
}, },
iconState: { circleState: {
width: '20px',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
marginRight: '10px', marginRight: '18px',
width: '20px',
zIndex: '100', zIndex: '100',
'& > img': { '& > img': {
display: 'block', display: 'block',
}, },
}, },
button: { button: {
justifyContent: 'center',
alignSelf: 'center', alignSelf: 'center',
flexGrow: '0',
fontSize: '16px',
justifyContent: 'center',
paddingLeft: '14px',
paddingRight: '14px',
},
lastButton: {
marginLeft: '10px',
}, },
executor: { executor: {
borderRadius: '3px',
padding: '3px 5px',
background: border,
color: primary,
alignSelf: 'center', alignSelf: 'center',
background: border,
borderRadius: '3px',
color: primary,
fontSize: '11px', fontSize: '11px',
height: '24px',
lineHeight: '24px',
padding: '0 12px',
}, },
}) })

View File

@ -30,7 +30,7 @@ type Props = {
closeSnackbar: Function, closeSnackbar: Function,
} }
const CancelTxModal = ({ const RejectTxModal = ({
onClose, onClose,
isOpen, isOpen,
classes, classes,
@ -78,15 +78,14 @@ const CancelTxModal = ({
return ( return (
<Modal <Modal
title="Cancel Transaction" title="Reject Transaction"
description="Cancel Transaction" description="Reject Transaction"
handleClose={onClose} handleClose={onClose}
open={isOpen} open={isOpen}
// paperClassName={cn(smallerModalSize && classes.smallerModalWindow)}
> >
<Row align="center" grow className={classes.heading}> <Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.headingText} noMargin> <Paragraph weight="bolder" className={classes.headingText} noMargin>
Cancel transaction Reject transaction
</Paragraph> </Paragraph>
<IconButton onClick={onClose} disableRipple> <IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} /> <Close className={classes.closeIcon} />
@ -96,8 +95,7 @@ const CancelTxModal = ({
<Block className={classes.container}> <Block className={classes.container}>
<Row> <Row>
<Paragraph> <Paragraph>
This action will cancel this transaction. A separate transaction will be performed to submit the This action will cancel this transaction. A separate transaction will be performed to submit the rejection.
cancellation.
</Paragraph> </Paragraph>
<Paragraph size="sm" color="medium"> <Paragraph size="sm" color="medium">
Transaction nonce: Transaction nonce:
@ -123,11 +121,11 @@ const CancelTxModal = ({
color="secondary" color="secondary"
onClick={sendReplacementTransaction} onClick={sendReplacementTransaction}
> >
Cancel Transaction Reject Transaction
</Button> </Button>
</Row> </Row>
</Modal> </Modal>
) )
} }
export default withStyles(styles)(withSnackbar(CancelTxModal)) export default withStyles(styles)(withSnackbar(RejectTxModal))

View File

@ -15,7 +15,7 @@ import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Owner } from '~/routes/safe/store/models/owner' import { type Owner } from '~/routes/safe/store/models/owner'
import TxDescription from './TxDescription' import TxDescription from './TxDescription'
import OwnersColumn from './OwnersColumn' import OwnersColumn from './OwnersColumn'
import CancelTxModal from './CancelTxModal' import RejectTxModal from './RejectTxModal'
import ApproveTxModal from './ApproveTxModal' import ApproveTxModal from './ApproveTxModal'
import { styles } from './style' import { styles } from './style'
import { formatDate } from '../columns' import { formatDate } from '../columns'
@ -24,6 +24,7 @@ import { INCOMING_TX_TYPE } from '~/routes/safe/store/models/incomingTransaction
type Props = { type Props = {
tx: Transaction, tx: Transaction,
cancelTx: Transaction,
threshold: number, threshold: number,
owners: List<Owner>, owners: List<Owner>,
granted: boolean, granted: boolean,
@ -34,12 +35,13 @@ type Props = {
nonce: number nonce: number
} }
type OpenModal = "cancelTx" | "approveTx" | null type OpenModal = "rejectTx" | "approveTx" | "executeRejectTx" | null
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const ExpandedTx = ({ const ExpandedTx = ({
tx, tx,
cancelTx,
threshold, threshold,
owners, owners,
granted, granted,
@ -52,10 +54,19 @@ const ExpandedTx = ({
const classes = useStyles() const classes = useStyles()
const [openModal, setOpenModal] = useState<OpenModal>(null) const [openModal, setOpenModal] = useState<OpenModal>(null)
const openApproveModal = () => setOpenModal('approveTx') const openApproveModal = () => setOpenModal('approveTx')
const openCancelModal = () => setOpenModal('cancelTx')
const closeModal = () => setOpenModal(null) const closeModal = () => setOpenModal(null)
const thresholdReached = tx.type !== INCOMING_TX_TYPE && threshold <= tx.confirmations.size const thresholdReached = tx.type !== INCOMING_TX_TYPE && threshold <= tx.confirmations.size
const canExecute = tx.type !== INCOMING_TX_TYPE && nonce === tx.nonce 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 ( return (
<> <>
@ -136,29 +147,23 @@ const ExpandedTx = ({
{tx.type !== INCOMING_TX_TYPE && ( {tx.type !== INCOMING_TX_TYPE && (
<OwnersColumn <OwnersColumn
tx={tx} tx={tx}
cancelTx={cancelTx}
owners={owners} owners={owners}
granted={granted} granted={granted}
canExecute={canExecute} canExecute={canExecute}
canExecuteCancel={canExecuteCancel}
threshold={threshold} threshold={threshold}
userAddress={userAddress} userAddress={userAddress}
thresholdReached={thresholdReached} thresholdReached={thresholdReached}
cancelThresholdReached={cancelThresholdReached}
safeAddress={safeAddress} safeAddress={safeAddress}
onTxConfirm={openApproveModal} onTxConfirm={openApproveModal}
onTxCancel={openCancelModal}
onTxExecute={openApproveModal} onTxExecute={openApproveModal}
onTxReject={openRejectModal}
/> />
)} )}
</Row> </Row>
</Block> </Block>
{openModal === 'cancelTx' && (
<CancelTxModal
isOpen
createTransaction={createTransaction}
onClose={closeModal}
tx={tx}
safeAddress={safeAddress}
/>
)}
{openModal === 'approveTx' && ( {openModal === 'approveTx' && (
<ApproveTxModal <ApproveTxModal
isOpen isOpen
@ -172,6 +177,29 @@ const ExpandedTx = ({
thresholdReached={thresholdReached} thresholdReached={thresholdReached}
/> />
)} )}
{openModal === 'rejectTx' && (
<RejectTxModal
isOpen
createTransaction={createTransaction}
onClose={closeModal}
tx={tx}
safeAddress={safeAddress}
/>
)}
{openModal === 'executeRejectTx' && (
<ApproveTxModal
isOpen
isCancelTx
processTransaction={processTransaction}
onClose={closeModal}
canExecute={canExecuteCancel}
tx={cancelTx}
userAddress={userAddress}
safeAddress={safeAddress}
threshold={threshold}
thresholdReached={cancelThresholdReached}
/>
)}
</> </>
) )
} }

View File

@ -11,8 +11,9 @@ export const styles = () => ({
padding: `${lg} ${md}`, padding: `${lg} ${md}`,
}, },
txData: { txData: {
display: 'flex',
alignItems: 'center', alignItems: 'center',
display: 'flex',
lineHeight: '1.6',
}, },
awaiting_your_confirmation: { awaiting_your_confirmation: {
color: disabled, color: disabled,

View File

@ -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()
})
})

View File

@ -2,7 +2,7 @@
import React from 'react' import React from 'react'
import { format, getTime, parseISO } from 'date-fns' import { format, getTime, parseISO } from 'date-fns'
import { BigNumber } from 'bignumber.js' import { BigNumber } from 'bignumber.js'
import { List } from 'immutable' import { List, Map } from 'immutable'
import TxType from './TxType' import TxType from './TxType'
import { type Transaction } from '~/routes/safe/store/models/transaction' import { type Transaction } from '~/routes/safe/store/models/transaction'
import { INCOMING_TX_TYPE, type IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction' 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_AMOUNT_ID = 'amount'
export const TX_TABLE_STATUS_ID = 'status' export const TX_TABLE_STATUS_ID = 'status'
export const TX_TABLE_RAW_TX_ID = 'tx' export const TX_TABLE_RAW_TX_ID = 'tx'
export const TX_TABLE_RAW_CANCEL_TX_ID = 'cancelTx'
export const TX_TABLE_EXPAND_ICON = 'expand' export const TX_TABLE_EXPAND_ICON = 'expand'
type TxData = { type TxData = {
id: number, id: ?number,
type: React.ReactNode, type: React.ReactNode,
date: string, date: string,
dateOrder?: number, dateOrder?: number,
@ -63,7 +64,10 @@ const getIncomingTxTableData = (tx: IncomingTransaction): TransactionRow => ({
[TX_TABLE_RAW_TX_ID]: tx, [TX_TABLE_RAW_TX_ID]: tx,
}) })
const getTransactionTableData = (tx: Transaction): TransactionRow => { const getTransactionTableData = (
tx: Transaction,
cancelTx: ?Transaction,
): TransactionRow => {
const txDate = tx.submissionDate const txDate = tx.submissionDate
let txType = 'outgoing' let txType = 'outgoing'
@ -85,16 +89,27 @@ const getTransactionTableData = (tx: Transaction): TransactionRow => {
[TX_TABLE_AMOUNT_ID]: getTxAmount(tx), [TX_TABLE_AMOUNT_ID]: getTxAmount(tx),
[TX_TABLE_STATUS_ID]: tx.status, [TX_TABLE_STATUS_ID]: tx.status,
[TX_TABLE_RAW_TX_ID]: tx, [TX_TABLE_RAW_TX_ID]: tx,
[TX_TABLE_RAW_CANCEL_TX_ID]: cancelTx,
} }
} }
export const getTxTableData = (transactions: List<Transaction | IncomingTransaction>): List<TransactionRow> => transactions.map((tx) => { export const getTxTableData = (
transactions: List<Transaction | IncomingTransaction>,
cancelTxs: List<Transaction>,
): List<TransactionRow> => {
const cancelTxsByNonce = cancelTxs.reduce((acc, tx) => acc.set(tx.nonce, tx), Map())
return transactions.map((tx) => {
if (tx.type === INCOMING_TX_TYPE) { if (tx.type === INCOMING_TX_TYPE) {
return getIncomingTxTableData(tx) return getIncomingTxTableData(tx)
} }
return getTransactionTableData(tx) return getTransactionTableData(
tx,
Number.isInteger(Number.parseInt(tx.nonce, 10)) ? cancelTxsByNonce.get(tx.nonce) : undefined,
)
}) })
}
export const generateColumns = () => { export const generateColumns = () => {
const nonceColumn: Column = { const nonceColumn: Column = {

View File

@ -21,10 +21,12 @@ import {
generateColumns, generateColumns,
TX_TABLE_ID, TX_TABLE_ID,
TX_TABLE_RAW_TX_ID, TX_TABLE_RAW_TX_ID,
TX_TABLE_RAW_CANCEL_TX_ID,
type TransactionRow, type TransactionRow,
} from './columns' } from './columns'
import { styles } from './style' import { styles } from './style'
import Status from './Status' import Status from './Status'
import type { IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
export const TRANSACTION_ROW_TEST_ID = 'transaction-row' export const TRANSACTION_ROW_TEST_ID = 'transaction-row'
@ -35,7 +37,8 @@ const expandCellStyle = {
type Props = { type Props = {
classes: Object, classes: Object,
transactions: List<Transaction>, transactions: List<Transaction | IncomingTransaction>,
cancellationTransactions: List<Transaction>,
threshold: number, threshold: number,
owners: List<Owner>, owners: List<Owner>,
userAddress: string, userAddress: string,
@ -49,6 +52,7 @@ type Props = {
const TxsTable = ({ const TxsTable = ({
classes, classes,
transactions, transactions,
cancellationTransactions,
threshold, threshold,
owners, owners,
granted, granted,
@ -66,7 +70,7 @@ const TxsTable = ({
const columns = generateColumns() const columns = generateColumns()
const autoColumns = columns.filter((c) => !c.custom) const autoColumns = columns.filter((c) => !c.custom)
const filteredData = getTxTableData(transactions) const filteredData = getTxTableData(transactions, cancellationTransactions)
.sort(({ dateOrder: a }, { dateOrder: b }) => { .sort(({ dateOrder: a }, { dateOrder: b }) => {
if (!a || !b) { if (!a || !b) {
return 0 return 0
@ -132,6 +136,7 @@ const TxsTable = ({
component={ExpandedTxComponent} component={ExpandedTxComponent}
unmountOnExit unmountOnExit
tx={row[TX_TABLE_RAW_TX_ID]} tx={row[TX_TABLE_RAW_TX_ID]}
cancelTx={row[TX_TABLE_RAW_CANCEL_TX_ID]}
threshold={threshold} threshold={threshold}
owners={owners} owners={owners}
granted={granted} granted={granted}

View File

@ -10,6 +10,7 @@ type Props = {
safeAddress: string, safeAddress: string,
threshold: number, threshold: number,
transactions: List<Transaction | IncomingTransaction>, transactions: List<Transaction | IncomingTransaction>,
cancellationTransactions: List<Transaction>,
owners: List<Owner>, owners: List<Owner>,
userAddress: string, userAddress: string,
granted: boolean, granted: boolean,
@ -21,6 +22,7 @@ type Props = {
const Transactions = ({ const Transactions = ({
transactions = List(), transactions = List(),
cancellationTransactions = List(),
owners, owners,
threshold, threshold,
userAddress, userAddress,
@ -33,6 +35,7 @@ const Transactions = ({
}: Props) => ( }: Props) => (
<TxsTable <TxsTable
transactions={transactions} transactions={transactions}
cancellationTransactions={cancellationTransactions}
threshold={threshold} threshold={threshold}
owners={owners} owners={owners}
userAddress={userAddress} userAddress={userAddress}

View File

@ -137,6 +137,7 @@ class SafeView extends React.Component<Props, State> {
fetchTokens, fetchTokens,
updateSafe, updateSafe,
transactions, transactions,
cancellationTransactions,
currencySelected, currencySelected,
fetchCurrencyValues, fetchCurrencyValues,
currencyValues, currencyValues,
@ -161,6 +162,7 @@ class SafeView extends React.Component<Props, State> {
fetchTokens={fetchTokens} fetchTokens={fetchTokens}
updateSafe={updateSafe} updateSafe={updateSafe}
transactions={transactions} transactions={transactions}
cancellationTransactions={cancellationTransactions}
sendFunds={sendFunds} sendFunds={sendFunds}
showReceive={showReceive} showReceive={showReceive}
onShow={this.onShow} onShow={this.onShow}

View File

@ -7,6 +7,7 @@ import {
safeBalancesSelector, safeBalancesSelector,
safeBlacklistedTokensSelector, safeBlacklistedTokensSelector,
safeTransactionsSelector, safeTransactionsSelector,
safeCancellationTransactionsSelector,
safeIncomingTransactionsSelector, safeIncomingTransactionsSelector,
type RouterProps, type RouterProps,
type SafeSelectorProps, type SafeSelectorProps,
@ -38,6 +39,7 @@ export type SelectorProps = {
currencySelected: string, currencySelected: string,
currencyValues: BalanceCurrencyType[], currencyValues: BalanceCurrencyType[],
transactions: List<Transaction | IncomingTransaction>, transactions: List<Transaction | IncomingTransaction>,
cancellationTransactions: List<Transaction>,
addressBook: AddressBook, addressBook: AddressBook,
} }
@ -108,21 +110,15 @@ const extendedTransactionsSelector: Selector<GlobalState, RouterProps, List<Tran
safeSelector, safeSelector,
userAccountSelector, userAccountSelector,
safeTransactionsSelector, safeTransactionsSelector,
safeCancellationTransactionsSelector,
safeIncomingTransactionsSelector, safeIncomingTransactionsSelector,
(safe, userAddress, transactions, incomingTransactions) => { (safe, userAddress, transactions, cancellationTransactions, incomingTransactions) => {
const cancellationTransactionsByNonce = cancellationTransactions.reduce((acc, tx) => acc.set(tx.nonce, tx), Map())
const extendedTransactions = transactions.map((tx: Transaction) => { const extendedTransactions = transactions.map((tx: Transaction) => {
let extendedTx = tx 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) { if (!tx.isExecuted) {
replacementTransaction = transactions.size > 1 && transactions.findLast( if (cancellationTransactionsByNonce.get(tx.nonce) && cancellationTransactionsByNonce.get(tx.nonce).get('isExecuted')) {
(transaction) => (
transaction.isExecuted && transaction.nonce && transaction.nonce >= tx.nonce
),
)
if (replacementTransaction) {
extendedTx = tx.set('cancelled', true) extendedTx = tx.set('cancelled', true)
} }
} }
@ -145,6 +141,7 @@ export default createStructuredSelector<Object, *>({
network: networkSelector, network: networkSelector,
safeUrl: safeParamAddressSelector, safeUrl: safeParamAddressSelector,
transactions: extendedTransactionsSelector, transactions: extendedTransactionsSelector,
cancellationTransactions: safeCancellationTransactionsSelector,
currencySelected: currentCurrencySelector, currencySelected: currentCurrencySelector,
currencyValues: currencyValuesListSelector, currencyValues: currencyValuesListSelector,
addressBook: getAddressBook, addressBook: getAddressBook,

View File

@ -0,0 +1,6 @@
// @flow
import { createAction } from 'redux-actions'
export const ADD_CANCELLATION_TRANSACTIONS = 'ADD_CANCELLATION_TRANSACTIONS'
export const addCancellationTransactions = createAction<string, *>(ADD_CANCELLATION_TRANSACTIONS)

View File

@ -78,7 +78,9 @@ const createTransaction = ({
const from = userAccountSelector(state) const from = userAccountSelector(state)
const safeInstance = await getGnosisSafeInstanceAt(safeAddress) const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const threshold = await safeInstance.getThreshold() 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 const isExecution = threshold.toNumber() === 1 || shouldExecute
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures // https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { List, Map } from 'immutable' import { List, Map, type RecordInstance } from 'immutable'
import axios from 'axios' import axios from 'axios'
import bn from 'bignumber.js' import bn from 'bignumber.js'
import type { Dispatch as ReduxDispatch } from 'redux' 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 { isTokenTransfer } from '~/logic/tokens/utils/tokenHelpers'
import { decodeParamsFromSafeMethod } from '~/logic/contracts/methodIds' import { decodeParamsFromSafeMethod } from '~/logic/contracts/methodIds'
import { ALTERNATIVE_TOKEN_ABI } from '~/logic/tokens/utils/alternativeAbi' 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 let web3
@ -33,22 +35,23 @@ type ConfirmationServiceModel = {
type TxServiceModel = { type TxServiceModel = {
to: string, to: string,
value: number, value: number,
data: string, data: ?string,
operation: number, operation: number,
nonce: number, nonce: ?number,
blockNumber: number, blockNumber: ?number,
safeTxGas: number, safeTxGas: number,
baseGas: number, baseGas: number,
gasPrice: number, gasPrice: number,
gasToken: string, gasToken: string,
refundReceiver: string, refundReceiver: string,
safeTxHash: string, safeTxHash: string,
submissionDate: string, submissionDate: ?string,
executor: string, executor: string,
executionDate: string, executionDate: ?string,
confirmations: ConfirmationServiceModel[], confirmations: ConfirmationServiceModel[],
isExecuted: boolean, isExecuted: boolean,
transactionHash: string, transactionHash: ?string,
creationTx?: boolean,
} }
type IncomingTxServiceModel = { type IncomingTxServiceModel = {
@ -63,7 +66,7 @@ type IncomingTxServiceModel = {
export const buildTransactionFrom = async ( export const buildTransactionFrom = async (
safeAddress: string, safeAddress: string,
tx: TxServiceModel, tx: TxServiceModel,
) => { ): Promise<Transaction> => {
const { owners } = await getLocalSafe(safeAddress) const { owners } = await getLocalSafe(safeAddress)
const confirmations = List( const confirmations = List(
@ -88,7 +91,7 @@ export const buildTransactionFrom = async (
) )
const modifySettingsTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !!tx.data 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 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 const customTx = !sameAddress(tx.to, safeAddress) && !!tx.data && !isSendTokenTx
let refundParams = null let refundParams = null
@ -173,7 +176,8 @@ export const buildTransactionFrom = async (
}) })
} }
const addMockSafeCreationTx = (safeAddress) => [{ const addMockSafeCreationTx = (safeAddress): Array<TxServiceModel> => [{
blockNumber: null,
baseGas: 0, baseGas: 0,
confirmations: [], confirmations: [],
data: null, data: null,
@ -233,8 +237,14 @@ export const buildIncomingTransactionFrom = async (tx: IncomingTxServiceModel) =
}) })
} }
export const loadSafeTransactions = async (safeAddress: string) => { export type SafeTransactionsType = {
outgoing: Map<string, List<TransactionProps>>,
cancel: Map<string, List<TransactionProps>>,
}
export const loadSafeTransactions = async (safeAddress: string): Promise<SafeTransactionsType> => {
let transactions: TxServiceModel[] = addMockSafeCreationTx(safeAddress) let transactions: TxServiceModel[] = addMockSafeCreationTx(safeAddress)
try { try {
const url = buildTxServiceUrl(safeAddress) const url = buildTxServiceUrl(safeAddress)
const response = await axios.get(url) 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) console.error(`Requests for outgoing transactions for ${safeAddress} failed with 404`, err)
} }
const txsRecord = await Promise.all( const txsRecord: Array<RecordInstance<TransactionProps>> = await Promise.all(
transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx)), 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) => { export const loadSafeIncomingTransactions = async (safeAddress: string) => {
@ -272,9 +287,10 @@ export const loadSafeIncomingTransactions = async (safeAddress: string) => {
export default (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => { export default (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
web3 = await getWeb3() web3 = await getWeb3()
const transactions: Map<string, List<Transaction>> = await loadSafeTransactions(safeAddress) const { outgoing, cancel }: SafeTransactionsType = await loadSafeTransactions(safeAddress)
const incomingTransactions: Map<string, List<IncomingTransaction>> = await loadSafeIncomingTransactions(safeAddress) const incomingTransactions: Map<string, List<IncomingTransaction>> = await loadSafeIncomingTransactions(safeAddress)
dispatch(addTransactions(transactions)) dispatch(addCancellationTransactions(cancel))
dispatch(addTransactions(outgoing))
dispatch(addIncomingTransactions(incomingTransactions)) dispatch(addIncomingTransactions(incomingTransactions))
} }

View File

@ -1,6 +1,6 @@
// @flow // @flow
import type { Action, Store } from 'redux' import type { Action, Store } from 'redux'
import { List } from 'immutable' import { List, Map } from 'immutable'
import { push } from 'connected-react-router' import { push } from 'connected-react-router'
import { type GlobalState } from '~/store/' import { type GlobalState } from '~/store/'
import { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/addTransactions' import { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/addTransactions'
@ -32,8 +32,11 @@ const notificationsMiddleware = (store: Store<GlobalState>) => (
const transactionsList = action.payload const transactionsList = action.payload
const userAddress: string = userAccountSelector(state) const userAddress: string = userAccountSelector(state)
const safeAddress = action.payload.keySeq().get(0) 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( const awaitingTransactions = getAwaitingTransactions(
transactionsList, transactionsList,
cancellationTransactionsByNonce,
userAddress, userAddress,
) )
const awaitingTransactionsList = awaitingTransactions.get( const awaitingTransactionsList = awaitingTransactions.get(

View File

@ -18,6 +18,27 @@ export type IncomingTransactionProps = {
executionDate: string, executionDate: string,
type: string, type: string,
status: 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<IncomingTransactionProps> = Record({ export const makeIncomingTransaction: RecordFactory<IncomingTransactionProps> = Record({
@ -34,6 +55,27 @@ export const makeIncomingTransaction: RecordFactory<IncomingTransactionProps> =
executionDate: '', executionDate: '',
type: INCOMING_TX_TYPE, type: INCOMING_TX_TYPE,
status: 'success', 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<IncomingTransactionProps> export type IncomingTransaction = RecordOf<IncomingTransactionProps>

View File

@ -17,8 +17,8 @@ export type TransactionStatus =
| 'pending' | 'pending'
export type TransactionProps = { export type TransactionProps = {
nonce: number, nonce: ?number,
blockNumber: number, blockNumber: ?number,
value: string, value: string,
confirmations: List<Confirmation>, confirmations: List<Confirmation>,
recipient: string, recipient: string,
@ -30,8 +30,8 @@ export type TransactionProps = {
gasToken: string, gasToken: string,
refundReceiver: string, refundReceiver: string,
isExecuted: boolean, isExecuted: boolean,
submissionDate: string, submissionDate: ?string,
executionDate: string, executionDate: ?string,
symbol: string, symbol: string,
modifySettingsTx: boolean, modifySettingsTx: boolean,
cancellationTx: boolean, cancellationTx: boolean,
@ -39,7 +39,7 @@ export type TransactionProps = {
creationTx: boolean, creationTx: boolean,
safeTxHash: string, safeTxHash: string,
executor: string, executor: string,
executionTxHash?: string, executionTxHash?: ?string,
decimals?: number, decimals?: number,
cancelled?: boolean, cancelled?: boolean,
status?: TransactionStatus, status?: TransactionStatus,

View File

@ -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<string, List<Transaction>>
export default handleActions<CancelState, *>(
{
[ADD_CANCELLATION_TRANSACTIONS]: (state: CancelState, action: ActionType<Function>): CancelState => action.payload,
},
Map(),
)

View File

@ -6,6 +6,10 @@ import { type GlobalState } from '~/store/index'
import { SAFE_PARAM_ADDRESS, SAFELIST_ADDRESS } from '~/routes/routes' import { SAFE_PARAM_ADDRESS, SAFELIST_ADDRESS } from '~/routes/routes'
import { type Safe } from '~/routes/safe/store/models/safe' 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 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 { import {
type IncomingState as IncomingTransactionsState, type IncomingState as IncomingTransactionsState,
INCOMING_TRANSACTIONS_REDUCER_ID, INCOMING_TRANSACTIONS_REDUCER_ID,
@ -48,13 +52,21 @@ export const defaultSafeSelector: Selector<GlobalState, {}, string> = createSele
const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID] 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 const oneTransactionSelector = (state: GlobalState, props: TransactionProps) => props.transaction
export const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || '' export const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || ''
export const safeTransactionsSelector: Selector<GlobalState, RouterProps, List<Transaction>> = createSelector( type TxSelectorType = Selector<GlobalState, RouterProps, List<Transaction>>
export const safeTransactionsSelector: TxSelectorType = createSelector(
transactionsSelector, transactionsSelector,
safeParamAddressSelector, safeParamAddressSelector,
(transactions: TransactionsState, address: string): List<Transaction> => { (transactions: TransactionsState, address: string): List<Transaction> => {
@ -80,6 +92,22 @@ export const addressBookQueryParamsSelector = (state: GlobalState): string => {
return entryAddressToEditOrCreateNew return entryAddressToEditOrCreateNew
} }
export const safeCancellationTransactionsSelector: TxSelectorType = createSelector(
cancellationTransactionsSelector,
safeParamAddressSelector,
(cancellationTransactions: TransactionsState, address: string): List<Transaction> => {
if (!cancellationTransactions) {
return List([])
}
if (!address) {
return List([])
}
return cancellationTransactions.get(address) || List([])
},
)
export const safeParamAddressFromStateSelector = (state: GlobalState): string => { export const safeParamAddressFromStateSelector = (state: GlobalState): string => {
const match = matchPath( const match = matchPath(
state.router.location.pathname, state.router.location.pathname,
@ -89,7 +117,9 @@ export const safeParamAddressFromStateSelector = (state: GlobalState): string =>
return match ? match.params.safeAddress : null return match ? match.params.safeAddress : null
} }
export const safeIncomingTransactionsSelector: Selector<GlobalState, RouterProps, List<IncomingTransaction>> = createSelector( type IncomingTxSelectorType = Selector<GlobalState, RouterProps, List<IncomingTransaction>>
export const safeIncomingTransactionsSelector: IncomingTxSelectorType = createSelector(
incomingTransactionsSelector, incomingTransactionsSelector,
safeParamAddressSelector, safeParamAddressSelector,
(incomingTransactions: IncomingTransactionsState, address: string): List<IncomingTransaction> => { (incomingTransactions: IncomingTransactionsState, address: string): List<IncomingTransaction> => {

View File

@ -2,7 +2,7 @@
import { createBrowserHistory } from 'history' import { createBrowserHistory } from 'history'
import { connectRouter, routerMiddleware } from 'connected-react-router' import { connectRouter, routerMiddleware } from 'connected-react-router'
import { import {
combineReducers, createStore, applyMiddleware, compose, type Reducer, type Store, combineReducers, createStore, applyMiddleware, compose, type CombinedReducer, type Store,
} from 'redux' } from 'redux'
import thunk from 'redux-thunk' import thunk from 'redux-thunk'
import safe, { SAFE_REDUCER_ID, type SafeReducerState as SafeState } from '~/routes/safe/store/reducer/safe' 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, type State as TransactionsState,
TRANSACTIONS_REDUCER_ID, TRANSACTIONS_REDUCER_ID,
} from '~/routes/safe/store/reducer/transactions' } from '~/routes/safe/store/reducer/transactions'
import cancellationTransactions, {
type CancelState as CancelTransactionsState,
CANCELLATION_TRANSACTIONS_REDUCER_ID,
} from '~/routes/safe/store/reducer/cancellationTransactions'
import incomingTransactions, { import incomingTransactions, {
type IncomingState as IncomingTransactionsState, type IncomingState as IncomingTransactionsState,
INCOMING_TRANSACTIONS_REDUCER_ID, INCOMING_TRANSACTIONS_REDUCER_ID,
@ -36,15 +40,21 @@ export const history = createBrowserHistory()
// eslint-disable-next-line // eslint-disable-next-line
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const finalCreateStore = composeEnhancers( const finalCreateStore = composeEnhancers(applyMiddleware(
applyMiddleware(thunk, routerMiddleware(history), safeStorage, providerWatcher, notificationsMiddleware, addressBookMiddleware), thunk,
) routerMiddleware(history),
safeStorage,
providerWatcher,
notificationsMiddleware,
addressBookMiddleware,
))
export type GlobalState = { export type GlobalState = {
providers: ProviderState, providers: ProviderState,
safes: SafeState, safes: SafeState,
tokens: TokensState, tokens: TokensState,
transactions: TransactionsState, transactions: TransactionsState,
cancellationTransactions: CancelTransactionsState,
incomingTransactions: IncomingTransactionsState, incomingTransactions: IncomingTransactionsState,
notifications: NotificationsState, notifications: NotificationsState,
currentSession: CurrentSessionState, currentSession: CurrentSessionState,
@ -52,12 +62,13 @@ export type GlobalState = {
export type GetState = () => GlobalState export type GetState = () => GlobalState
const reducers: Reducer<GlobalState> = combineReducers({ const reducers: CombinedReducer<GlobalState, *> = combineReducers({
router: connectRouter(history), router: connectRouter(history),
[PROVIDER_REDUCER_ID]: provider, [PROVIDER_REDUCER_ID]: provider,
[SAFE_REDUCER_ID]: safe, [SAFE_REDUCER_ID]: safe,
[TOKEN_REDUCER_ID]: tokens, [TOKEN_REDUCER_ID]: tokens,
[TRANSACTIONS_REDUCER_ID]: transactions, [TRANSACTIONS_REDUCER_ID]: transactions,
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions,
[INCOMING_TRANSACTIONS_REDUCER_ID]: incomingTransactions, [INCOMING_TRANSACTIONS_REDUCER_ID]: incomingTransactions,
[NOTIFICATIONS_REDUCER_ID]: notifications, [NOTIFICATIONS_REDUCER_ID]: notifications,
[CURRENCY_VALUES_KEY]: currencyValues, [CURRENCY_VALUES_KEY]: currencyValues,
@ -66,7 +77,10 @@ const reducers: Reducer<GlobalState> = combineReducers({
[CURRENT_SESSION_REDUCER_ID]: currentSession, [CURRENT_SESSION_REDUCER_ID]: currentSession,
}) })
export const store: Store<GlobalState> = createStore(reducers, finalCreateStore) export const store: Store<GlobalState, *> = createStore(reducers, finalCreateStore)
// eslint-disable-next-line max-len export const aNewStore = (localState?: Object): Store<GlobalState, *> => createStore(
export const aNewStore = (localState?: Object): Store<GlobalState> => createStore(reducers, localState, finalCreateStore) reducers,
localState,
finalCreateStore,
)