* 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:
parent
226b525c7e
commit
3e5d4f6646
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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))
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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 = {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
)
|
|
@ -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> => {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue