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 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<boolean>(false)
const classes = useStyles()
const customClasses = increaseZindex ? { popper: classes.increasedPopperZindex } : {}
@ -50,7 +52,7 @@ const CopyBtn = ({ content, increaseZindex = false }: CopyBtnProps) => {
}}
classes={customClasses}
>
<div className={classes.container}>
<div className={cn(classes.container, className)}>
<Img
src={CopyIcon}
height={20}

View File

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

View File

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

View File

@ -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',
},
})

View File

@ -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<GlobalState>) => {
try {
dispatch(updateAddressBook(addressBook))
dispatch(updateAddressBookEntry(addressBook))
await saveAddressBook(addressBook)
} catch (err) {
// eslint-disable-next-line

View File

@ -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) => (
<IconButton onClick={() => store.dispatch(closeSnackbarAction({ key }))}>
action: (actionKey: number) => (
<IconButton onClick={() => store.dispatch(closeSnackbarAction({ key: actionKey }))}>
<IconClose />
</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,
action: (key) => (
<IconButton onClick={() => closeSnackbar(key)}>
<IconClose />
</IconButton>
),
})
},
)

View File

@ -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 },

View File

@ -4,6 +4,7 @@ import type { Transaction } from '~/routes/safe/store/models/transaction'
export const getAwaitingTransactions = (
allTransactions: Map<string, List<Transaction>>,
cancellationTransactionsByNonce: Map<string | number, List<Transaction>>,
userAccount: string,
): Map<string, List<Transaction>> => {
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,
)

View File

@ -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,
}

View File

@ -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

View File

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

View File

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

View File

@ -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<boolean>(canExecute)
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 isTheTxReadyToBeExecuted = oneConfirmationLeft ? true : thresholdReached
@ -132,7 +142,7 @@ const ApproveTxModal = ({
</Row>
<Hairline />
<Block className={classes.container}>
<Row>
<Row style={{ flexDirection: 'column' }}>
<Paragraph>{description}</Paragraph>
<Paragraph size="sm" color="medium">
Transaction nonce:
@ -142,10 +152,11 @@ const ApproveTxModal = ({
{oneConfirmationLeft && canExecute && (
<>
<Paragraph color="error">
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.'}
</Paragraph>
{!isCancelTx && (
<FormControlLabel
control={(
<Checkbox
@ -156,6 +167,7 @@ const ApproveTxModal = ({
)}
label="Execute transaction"
/>
)}
</>
)}
</Row>
@ -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}
</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
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 (
<Block className={classes.container}>
<div
className={
confirmed || thresholdReached || executor
? classes.verticalLineProgressDone
: classes.verticalLineProgressPending
}
/>
<div className={classes.iconState}>
{confirmed ? (
<Img src={ConfirmSmallFilledIcon} />
) : thresholdReached || executor ? (
<Img src={ConfirmSmallGreenIcon} />
) : (
<Img src={ConfirmSmallGreyIcon} />
)}
<div className={cn(classes.verticalLine, (confirmed || thresholdReached || executor) && getTimelineLine())} />
<div className={classes.circleState}>
<Img src={imgCircle} alt="" />
</div>
<Identicon address={owner.address} diameter={32} className={classes.icon} />
<Block>
@ -75,30 +88,61 @@ const OwnerComponent = ({
/>
</Block>
<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
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
onClick={onTxConfirm}
testId={CONFIRM_TX_BTN_TEST_ID}
variant="contained"
>
Confirm tx
Confirm
</Button>
)}
{showExecuteBtn && owner.address === userAddress && (
{showExecuteBtn && (
<Button
className={classes.button}
variant="contained"
minWidth={140}
color="primary"
onClick={onTxExecute}
testId={EXECUTE_TX_BTN_TEST_ID}
variant="contained"
>
Execute tx
Execute
</Button>
)}
</>
)}
</Block>
)}
{owner.address === executor && (
<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'
type ListProps = {
ownersWhoConfirmed: List<Owner>,
ownersUnconfirmed: List<Owner>,
classes: Object,
userAddress: string,
executor: string,
thresholdReached: boolean,
showConfirmBtn: boolean,
showExecuteBtn: boolean,
isCancelTx?: boolean,
onTxReject?: Function,
onTxConfirm: Function,
onTxExecute: Function,
ownersUnconfirmed: List<Owner>,
ownersWhoConfirmed: List<Owner>,
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) => (
<OwnerComponent
key={owner.address}
owner={owner}
classes={classes}
userAddress={userAddress}
executor={executor}
thresholdReached={thresholdReached}
confirmed
showExecuteBtn={showExecuteBtn}
executor={executor}
isCancelTx={isCancelTx}
key={owner.address}
onTxReject={onTxReject}
onTxExecute={onTxExecute}
owner={owner}
showRejectBtn={showRejectBtn}
showExecuteRejectBtn={showExecuteRejectBtn}
showExecuteBtn={showExecuteBtn}
thresholdReached={thresholdReached}
userAddress={userAddress}
/>
))}
{ownersUnconfirmed.map((owner) => (
<OwnerComponent
key={owner.address}
owner={owner}
classes={classes}
userAddress={userAddress}
executor={executor}
thresholdReached={thresholdReached}
isCancelTx={isCancelTx}
key={owner.address}
onTxReject={onTxReject}
onTxConfirm={onTxConfirm}
onTxExecute={onTxExecute}
owner={owner}
showRejectBtn={showRejectBtn}
showExecuteRejectBtn={showExecuteRejectBtn}
showConfirmBtn={showConfirmBtn}
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 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<Owner>,
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<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
}
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 (tx.isExecuted || cancelTx.isExecuted) {
showOlderTxAnnotation = false
} else {
showOlderTxAnnotation = (thresholdReached && !canExecute) || (cancelThresholdReached && !canExecuteCancel)
}
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
})
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 (
<Col xs={6} className={classes.rightCol} layout="block">
<Block
@ -107,54 +154,85 @@ const OwnersColumn = ({
(thresholdReached || tx.isExecuted) && classes.ownerListTitleDone,
)}
>
<div className={classes.iconState}>
{thresholdReached || tx.isExecuted ? (
<Img src={CheckLargeFilledGreenIcon} />
) : (
<Img src={ConfirmLargeGreenIcon} />
)}
<div className={classes.circleState}>
<Img src={thresholdReached || tx.isExecuted ? CheckLargeFilledGreenCircle : ConfirmLargeGreenCircle} alt="" />
</div>
{tx.isExecuted
? `Confirmed [${tx.confirmations.size}/${tx.confirmations.size}]`
: `Confirmed [${tx.confirmations.size}/${threshold}]`}
: `Confirmed [${tx.confirmations.size}/${txThreshold}]`}
</Block>
<OwnersList
userAddress={userAddress}
ownersWhoConfirmed={ownersWhoConfirmed}
ownersUnconfirmed={ownersUnconfirmed}
executor={tx.executor}
thresholdReached={thresholdReached}
onTxConfirm={onTxConfirm}
onTxExecute={onTxExecute}
ownersUnconfirmed={ownersUnconfirmed}
ownersWhoConfirmed={ownersWhoConfirmed}
showConfirmBtn={showConfirmBtn}
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
className={cn(
classes.ownerListTitle,
tx.isExecuted && classes.ownerListTitleDone,
cancelTx.isExecuted && classes.ownerListTitleCancelDone,
)}
>
<div
className={
thresholdReached || tx.isExecuted
? classes.verticalLineProgressDone
: classes.verticalLineProgressPending
}
/>
<div className={classes.iconState}>
{!thresholdReached && !tx.isExecuted && (
<Img src={ConfirmLargeGreyIcon} alt="Confirm tx" />
className={cn(
classes.verticalLine,
tx.isExecuted && classes.verticalLineDone,
cancelTx.isExecuted && classes.verticalLineCancel,
)}
{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 && (
<Img src={CheckLargeFilledGreenIcon} alt="TX Executed icon" />
<Img src={CheckLargeFilledGreenCircle} alt="TX Executed icon" />
)}
{cancelTx.isExecuted && (
<Img src={CheckLargeFilledRedCircle} alt="TX Executed icon" />
)}
</div>
Executed
</Block>
{showOlderTxAnnotation && (
<Block className={classes.olderTxAnnotation}>
<Paragraph>
@ -162,9 +240,6 @@ const OwnersColumn = ({
</Paragraph>
</Block>
)}
{granted && displayButtonRow && (
<ButtonRow onTxCancel={onTxCancel} showCancelBtn={!cancellationTx} />
)}
</Col>
)
}

View File

@ -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',
},
})

View File

@ -30,7 +30,7 @@ type Props = {
closeSnackbar: Function,
}
const CancelTxModal = ({
const RejectTxModal = ({
onClose,
isOpen,
classes,
@ -78,15 +78,14 @@ const CancelTxModal = ({
return (
<Modal
title="Cancel Transaction"
description="Cancel Transaction"
title="Reject Transaction"
description="Reject Transaction"
handleClose={onClose}
open={isOpen}
// paperClassName={cn(smallerModalSize && classes.smallerModalWindow)}
>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.headingText} noMargin>
Cancel transaction
Reject transaction
</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
@ -96,8 +95,7 @@ const CancelTxModal = ({
<Block className={classes.container}>
<Row>
<Paragraph>
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.
</Paragraph>
<Paragraph size="sm" color="medium">
Transaction nonce:
@ -123,11 +121,11 @@ const CancelTxModal = ({
color="secondary"
onClick={sendReplacementTransaction}
>
Cancel Transaction
Reject Transaction
</Button>
</Row>
</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 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<Owner>,
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<OpenModal>(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 && (
<OwnersColumn
tx={tx}
cancelTx={cancelTx}
owners={owners}
granted={granted}
canExecute={canExecute}
canExecuteCancel={canExecuteCancel}
threshold={threshold}
userAddress={userAddress}
thresholdReached={thresholdReached}
cancelThresholdReached={cancelThresholdReached}
safeAddress={safeAddress}
onTxConfirm={openApproveModal}
onTxCancel={openCancelModal}
onTxExecute={openApproveModal}
onTxReject={openRejectModal}
/>
)}
</Row>
</Block>
{openModal === 'cancelTx' && (
<CancelTxModal
isOpen
createTransaction={createTransaction}
onClose={closeModal}
tx={tx}
safeAddress={safeAddress}
/>
)}
{openModal === 'approveTx' && (
<ApproveTxModal
isOpen
@ -172,6 +177,29 @@ const ExpandedTx = ({
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}`,
},
txData: {
display: 'flex',
alignItems: 'center',
display: 'flex',
lineHeight: '1.6',
},
awaiting_your_confirmation: {
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 { 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<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) {
return getIncomingTxTableData(tx)
}
return getTransactionTableData(tx)
})
return getTransactionTableData(
tx,
Number.isInteger(Number.parseInt(tx.nonce, 10)) ? cancelTxsByNonce.get(tx.nonce) : undefined,
)
})
}
export const generateColumns = () => {
const nonceColumn: Column = {

View File

@ -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<Transaction>,
transactions: List<Transaction | IncomingTransaction>,
cancellationTransactions: List<Transaction>,
threshold: number,
owners: List<Owner>,
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}

View File

@ -10,6 +10,7 @@ type Props = {
safeAddress: string,
threshold: number,
transactions: List<Transaction | IncomingTransaction>,
cancellationTransactions: List<Transaction>,
owners: List<Owner>,
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) => (
<TxsTable
transactions={transactions}
cancellationTransactions={cancellationTransactions}
threshold={threshold}
owners={owners}
userAddress={userAddress}

View File

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

View File

@ -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<Transaction | IncomingTransaction>,
cancellationTransactions: List<Transaction>,
addressBook: AddressBook,
}
@ -108,21 +110,15 @@ const extendedTransactionsSelector: Selector<GlobalState, RouterProps, List<Tran
safeSelector,
userAccountSelector,
safeTransactionsSelector,
safeCancellationTransactionsSelector,
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) => {
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<Object, *>({
network: networkSelector,
safeUrl: safeParamAddressSelector,
transactions: extendedTransactionsSelector,
cancellationTransactions: safeCancellationTransactionsSelector,
currencySelected: currentCurrencySelector,
currencyValues: currencyValuesListSelector,
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 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

View File

@ -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<Transaction> => {
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<TxServiceModel> => [{
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<string, List<TransactionProps>>,
cancel: Map<string, List<TransactionProps>>,
}
export const loadSafeTransactions = async (safeAddress: string): Promise<SafeTransactionsType> => {
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<RecordInstance<TransactionProps>> = 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<GlobalState>) => {
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)
dispatch(addTransactions(transactions))
dispatch(addCancellationTransactions(cancel))
dispatch(addTransactions(outgoing))
dispatch(addIncomingTransactions(incomingTransactions))
}

View File

@ -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<GlobalState>) => (
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(

View File

@ -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<IncomingTransactionProps> = Record({
@ -34,6 +55,27 @@ export const makeIncomingTransaction: RecordFactory<IncomingTransactionProps> =
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<IncomingTransactionProps>

View File

@ -17,8 +17,8 @@ export type TransactionStatus =
| 'pending'
export type TransactionProps = {
nonce: number,
blockNumber: number,
nonce: ?number,
blockNumber: ?number,
value: string,
confirmations: List<Confirmation>,
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,

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 { 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<GlobalState, {}, string> = 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<GlobalState, RouterProps, List<Transaction>> = createSelector(
type TxSelectorType = Selector<GlobalState, RouterProps, List<Transaction>>
export const safeTransactionsSelector: TxSelectorType = createSelector(
transactionsSelector,
safeParamAddressSelector,
(transactions: TransactionsState, address: string): List<Transaction> => {
@ -80,6 +92,22 @@ export const addressBookQueryParamsSelector = (state: GlobalState): string => {
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 => {
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<GlobalState, RouterProps, List<IncomingTransaction>> = createSelector(
type IncomingTxSelectorType = Selector<GlobalState, RouterProps, List<IncomingTransaction>>
export const safeIncomingTransactionsSelector: IncomingTxSelectorType = createSelector(
incomingTransactionsSelector,
safeParamAddressSelector,
(incomingTransactions: IncomingTransactionsState, address: string): List<IncomingTransaction> => {

View File

@ -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<GlobalState> = combineReducers({
const reducers: CombinedReducer<GlobalState, *> = 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<GlobalState> = combineReducers({
[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(reducers, localState, finalCreateStore)
export const aNewStore = (localState?: Object): Store<GlobalState, *> => createStore(
reducers,
localState,
finalCreateStore,
)