(Feature) Incoming transactions (#333)

* Add `blockNumber` to transactions model

* Create `incomingTransaction` node in store and load it along with `transactions`

* Add incoming transfers to the Transactions table

* Rename `transactionHash` to `executionTxHash` for better incoming/outgoing txs unification in Transactions table

* Add incoming transactions details

* Add transaction type icon in table row

* Add snackbar notification for incoming txs

* Make incoming transaction snackbar to show on any tab

* Use makeStyles hooks

* Fix incoming amounts conversion from wei

* Make concurrent promise calls

* Use date to calculate transactions ids

* Prevent repeating messages

- also move logic to display snack bar into the notifications middleware

* Merge transactions and incomingTxs to the transactions selector

* Show 'Multiple incoming transfers' if they are more than 3

* Prevent incoming transactions snack bar for first-timer users

* Set ID as the default order

* Use constant for _incoming_ type
This commit is contained in:
Fernando 2019-12-13 10:45:28 -03:00 committed by Mikhail Mikheev
parent 1e1592f957
commit d69e5fca7f
23 changed files with 492 additions and 129 deletions

View File

@ -52,6 +52,9 @@ export const getTxServiceHost = () => {
export const getTxServiceUriFrom = (safeAddress: string) =>
`safes/${safeAddress}/transactions/`
export const getIncomingTxServiceUriTo = (safeAddress: string) =>
`safes/${safeAddress}/incoming-transactions/`
export const getRelayUrl = () => getConfig()[RELAY_API_URL]
export const signaturesViaMetamask = () => {

View File

@ -8,6 +8,7 @@ import loadActiveTokens from '~/logic/tokens/store/actions/loadActiveTokens'
import loadDefaultSafe from '~/routes/safe/store/actions/loadDefaultSafe'
import loadSafesFromStorage from '~/routes/safe/store/actions/loadSafesFromStorage'
import { store } from '~/store'
import verifyRecurringUser from '~/utils/verifyRecurringUser'
BigNumber.set({ EXPONENTIAL_AT: [-7, 255] })
@ -21,6 +22,7 @@ if (process.env.NODE_ENV !== 'production') {
store.dispatch(loadActiveTokens())
store.dispatch(loadSafesFromStorage())
store.dispatch(loadDefaultSafe())
verifyRecurringUser()
const root = document.getElementById('root')

View File

@ -40,6 +40,7 @@ export type Notifications = {
TX_EXECUTED_MORE_CONFIRMATIONS_MSG: Notification,
TX_FAILED_MSG: Notification,
TX_WAITING_MSG: Notification,
TX_INCOMING_MSG: Notification,
// Approval Transactions
TX_CONFIRMATION_PENDING_MSG: Notification,
@ -131,6 +132,13 @@ export const NOTIFICATIONS: Notifications = {
variant: WARNING, persist: true, preventDuplicate: true,
},
},
TX_INCOMING_MSG: {
message: 'Incoming transfer: ',
key: 'TX_INCOMING_MSG',
options: {
variant: SUCCESS, persist: false, autoHideDuration: longDuration, preventDuplicate: true,
},
},
// Approval Transactions
TX_CONFIRMATION_PENDING_MSG: {

View File

@ -0,0 +1,11 @@
// @flow
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { getIncomingTxServiceUriTo, getTxServiceHost } from '~/config'
export const buildIncomingTxServiceUrl = (safeAddress: string) => {
const host = getTxServiceHost()
const address = getWeb3().utils.toChecksumAddress(safeAddress)
const base = getIncomingTxServiceUriTo(address)
return `${host}${base}`
}

View File

@ -63,7 +63,6 @@ const Layout = (props: Props) => {
blacklistedTokens,
createTransaction,
processTransaction,
fetchTransactions,
activateTokensByBalance,
fetchTokens,
updateSafe,
@ -178,7 +177,6 @@ const Layout = (props: Props) => {
owners={safe.owners}
nonce={safe.nonce}
transactions={transactions}
fetchTransactions={fetchTransactions}
safeAddress={address}
userAddress={userAddress}
currentNetwork={network}

View File

@ -0,0 +1,54 @@
// @flow
import React from 'react'
import { makeStyles } from '@material-ui/core/styles'
import type { IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
import Bold from '~/components/layout/Bold'
import EtherscanLink from '~/components/EtherscanLink'
import Paragraph from '~/components/layout/Paragraph'
import Block from '~/components/layout/Block'
import { md, lg } from '~/theme/variables'
import { getIncomingTxAmount } from '~/routes/safe/components/Transactions/TxsTable/columns'
export const TRANSACTIONS_DESC_INCOMING_TEST_ID = 'tx-description-incoming'
const useStyles = makeStyles({
txDataContainer: {
padding: `${lg} ${md}`,
borderRight: '2px solid rgb(232, 231, 230)',
},
})
type Props = {
tx: IncomingTransaction,
}
type TransferDescProps = {
value: string,
from: string,
}
const TransferDescription = ({ value = '', from }: TransferDescProps) => (
<Paragraph noMargin data-testid={TRANSACTIONS_DESC_INCOMING_TEST_ID}>
<Bold>
Received
{' '}
{value}
{' '}
from:
</Bold>
<br />
<EtherscanLink type="address" value={from} />
</Paragraph>
)
const IncomingTxDescription = ({ tx }: Props) => {
const classes = useStyles()
return (
<Block className={classes.txDataContainer}>
<TransferDescription value={getIncomingTxAmount(tx)} from={tx.from} />
</Block>
)
}
export default IncomingTxDescription

View File

@ -18,6 +18,8 @@ import CancelTxModal from './CancelTxModal'
import ApproveTxModal from './ApproveTxModal'
import { styles } from './style'
import { formatDate } from '../columns'
import IncomingTxDescription from '~/routes/safe/components/Transactions/TxsTable/ExpandedTx/IncomingTxDescription'
import { INCOMING_TX_TYPE } from '~/routes/safe/store/models/incomingTransaction'
type Props = {
classes: Object,
@ -59,15 +61,15 @@ const ExpandedTx = ({
const openApproveModal = () => setOpenModal('approveTx')
const openCancelModal = () => setOpenModal('cancelTx')
const closeModal = () => setOpenModal(null)
const thresholdReached = threshold <= tx.confirmations.size
const canExecute = nonce === tx.nonce
const thresholdReached = tx.type !== INCOMING_TX_TYPE && threshold <= tx.confirmations.size
const canExecute = tx.type !== INCOMING_TX_TYPE && nonce === tx.nonce
return (
<>
<Block className={classes.expandedTxBlock}>
<Row>
<Col xs={6} layout="column">
<Block className={classes.txDataContainer}>
<Block className={classes.txDataContainer} style={tx.type === INCOMING_TX_TYPE ? { borderRight: '2px solid rgb(232, 231, 230)' } : {}}>
<Block align="left" className={classes.txData}>
<Bold className={classes.txHash}>TX hash:</Bold>
{tx.executionTxHash ? (
@ -82,53 +84,70 @@ const ExpandedTx = ({
{txStatusToLabel[tx.status]}
</Span>
</Paragraph>
<Paragraph noMargin>
<Bold>TX created: </Bold>
{formatDate(tx.submissionDate)}
</Paragraph>
{tx.executionDate && (
<Paragraph noMargin>
<Bold>TX executed: </Bold>
{formatDate(tx.executionDate)}
</Paragraph>
)}
{tx.refundParams && (
<Paragraph noMargin>
<Bold>TX refund: </Bold>
max.
{' '}
{tx.refundParams.fee}
{' '}
{tx.refundParams.symbol}
</Paragraph>
)}
{tx.operation === 1 && (
<Paragraph noMargin>
<Bold>Delegate Call</Bold>
</Paragraph>
)}
{tx.operation === 2 && (
<Paragraph noMargin>
<Bold>Contract Creation</Bold>
</Paragraph>
{tx.type === INCOMING_TX_TYPE ? (
<>
<Paragraph noMargin>
<Bold>TX fee: </Bold>
{tx.fee}
</Paragraph>
<Paragraph noMargin>
<Bold>TX created: </Bold>
{formatDate(tx.executionDate)}
</Paragraph>
</>
) : (
<>
<Paragraph noMargin>
<Bold>TX created: </Bold>
{formatDate(tx.submissionDate)}
</Paragraph>
{tx.executionDate && (
<Paragraph noMargin>
<Bold>TX executed: </Bold>
{formatDate(tx.executionDate)}
</Paragraph>
)}
{tx.refundParams && (
<Paragraph noMargin>
<Bold>TX refund: </Bold>
max.
{' '}
{tx.refundParams.fee}
{' '}
{tx.refundParams.symbol}
</Paragraph>
)}
{tx.operation === 1 && (
<Paragraph noMargin>
<Bold>Delegate Call</Bold>
</Paragraph>
)}
{tx.operation === 2 && (
<Paragraph noMargin>
<Bold>Contract Creation</Bold>
</Paragraph>
)}
</>
)}
</Block>
<Hairline />
<TxDescription tx={tx} />
{tx.type === INCOMING_TX_TYPE ? <IncomingTxDescription tx={tx} /> : <TxDescription tx={tx} />}
</Col>
<OwnersColumn
tx={tx}
owners={owners}
granted={granted}
canExecute={canExecute}
threshold={threshold}
userAddress={userAddress}
thresholdReached={thresholdReached}
safeAddress={safeAddress}
onTxConfirm={openApproveModal}
onTxCancel={openCancelModal}
onTxExecute={openApproveModal}
/>
{tx.type !== INCOMING_TX_TYPE && (
<OwnersColumn
tx={tx}
owners={owners}
granted={granted}
canExecute={canExecute}
threshold={threshold}
userAddress={userAddress}
thresholdReached={thresholdReached}
safeAddress={safeAddress}
onTxConfirm={openApproveModal}
onTxCancel={openCancelModal}
onTxExecute={openApproveModal}
/>
)}
</Row>
</Block>
{openModal === 'cancelTx' && (

View File

@ -5,11 +5,12 @@ import { BigNumber } from 'bignumber.js'
import { List } 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'
import { type SortRow, buildOrderFieldFrom } from '~/components/Table/sorting'
import { type Column } from '~/components/Table/TableHead'
import { getWeb3 } from '~/logic/wallets/getWeb3'
export const TX_TABLE_NONCE_ID = 'nonce'
export const TX_TABLE_ID = 'id'
export const TX_TABLE_TYPE_ID = 'type'
export const TX_TABLE_DATE_ID = 'date'
export const TX_TABLE_AMOUNT_ID = 'amount'
@ -18,9 +19,10 @@ export const TX_TABLE_RAW_TX_ID = 'tx'
export const TX_TABLE_EXPAND_ICON = 'expand'
type TxData = {
nonce: number,
id: number,
type: React.ReactNode,
date: string,
dateOrder?: number,
amount: number | string,
tx: Transaction,
status?: string,
@ -28,6 +30,11 @@ type TxData = {
export const formatDate = (date: string): string => format(parseISO(date), 'MMM d, yyyy - HH:mm:ss')
export const getIncomingTxAmount = (tx: IncomingTransaction) => {
const txAmount = tx.value ? `${new BigNumber(tx.value).div(`1e${tx.decimals}`).toFixed()}` : 'n/a'
return `${txAmount} ${tx.symbol || 'n/a'}`
}
export const getTxAmount = (tx: Transaction) => {
const web3 = getWeb3()
const { toBN, fromWei } = web3.utils
@ -46,46 +53,56 @@ export const getTxAmount = (tx: Transaction) => {
export type TransactionRow = SortRow<TxData>
export const getTxTableData = (transactions: List<Transaction>): List<TransactionRow> => {
const rows = transactions.map((tx: Transaction) => {
const txDate = tx.isExecuted ? tx.executionDate : tx.submissionDate
let txType = 'outgoing'
if (tx.modifySettingsTx) {
txType = 'settings'
} else if (tx.cancellationTx) {
txType = 'cancellation'
} else if (tx.customTx) {
txType = 'custom'
} else if (tx.creationTx) {
txType = 'creation'
const getIncomingTxTableData = (tx: IncomingTransaction): TransactionRow => ({
[TX_TABLE_ID]: tx.blockNumber,
[TX_TABLE_TYPE_ID]: <TxType txType="incoming" />,
[TX_TABLE_DATE_ID]: formatDate(tx.executionDate),
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: getTime(parseISO(tx.executionDate)),
[TX_TABLE_AMOUNT_ID]: getIncomingTxAmount(tx),
[TX_TABLE_STATUS_ID]: tx.status,
[TX_TABLE_RAW_TX_ID]: tx,
})
const getTransactionTableData = (tx: Transaction): TransactionRow => {
const txDate = tx.isExecuted ? tx.executionDate : tx.submissionDate
let txType = 'outgoing'
if (tx.modifySettingsTx) {
txType = 'settings'
} else if (tx.cancellationTx) {
txType = 'cancellation'
} else if (tx.customTx) {
txType = 'custom'
} else if (tx.creationTx) {
txType = 'creation'
}
return {
[TX_TABLE_ID]: tx.blockNumber,
[TX_TABLE_TYPE_ID]: <TxType txType={txType} />,
[TX_TABLE_DATE_ID]: tx.isExecuted
? tx.executionDate && formatDate(tx.executionDate)
: tx.submissionDate && formatDate(tx.submissionDate),
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: txDate ? getTime(parseISO(txDate)) : null,
[TX_TABLE_AMOUNT_ID]: getTxAmount(tx),
[TX_TABLE_STATUS_ID]: tx.status,
[TX_TABLE_RAW_TX_ID]: tx,
}
}
export const getTxTableData = (transactions: List<Transaction | IncomingTransaction>): List<TransactionRow> => {
return transactions.map((tx) => {
if (tx.type === INCOMING_TX_TYPE) {
return getIncomingTxTableData(tx)
}
let txIndex = 1
if (tx.nonce) {
txIndex = tx.nonce + 2
} else if (tx.nonce === 0) {
txIndex = 2
}
return {
[TX_TABLE_NONCE_ID]: txIndex,
[TX_TABLE_TYPE_ID]: <TxType txType={txType} />,
[TX_TABLE_DATE_ID]: tx.isExecuted
? tx.executionDate && formatDate(tx.executionDate)
: tx.submissionDate && formatDate(tx.submissionDate),
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: txDate ? getTime(parseISO(txDate)) : null,
[TX_TABLE_AMOUNT_ID]: getTxAmount(tx),
[TX_TABLE_STATUS_ID]: tx.status,
[TX_TABLE_RAW_TX_ID]: tx,
}
return getTransactionTableData(tx)
})
return rows
}
export const generateColumns = () => {
const nonceColumn: Column = {
id: TX_TABLE_NONCE_ID,
id: TX_TABLE_ID,
disablePadding: false,
label: 'Id',
custom: false,

View File

@ -17,7 +17,11 @@ import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Owner } from '~/routes/safe/store/models/owner'
import ExpandedTxComponent from './ExpandedTx'
import {
getTxTableData, generateColumns, TX_TABLE_NONCE_ID, type TransactionRow, TX_TABLE_RAW_TX_ID,
getTxTableData,
generateColumns,
TX_TABLE_ID,
TX_TABLE_RAW_TX_ID,
type TransactionRow,
} from './columns'
import { styles } from './style'
import Status from './Status'
@ -63,12 +67,19 @@ const TxsTable = ({
const columns = generateColumns()
const autoColumns = columns.filter((c) => !c.custom)
const filteredData = getTxTableData(transactions)
.sort(({ dateOrder: a }, { dateOrder: b }) => {
if (!a || !b) {
return 0
}
return a - b
})
.map((tx, id) => ({ ...tx, id }))
return (
<Block className={classes.container}>
<Table
label="Transactions"
defaultOrderBy={TX_TABLE_NONCE_ID}
defaultOrderBy={TX_TABLE_ID}
defaultOrder="desc"
defaultRowsPerPage={25}
columns={columns}

View File

@ -1,26 +1,24 @@
// @flow
import React, { useEffect } from 'react'
import React from 'react'
import { List } from 'immutable'
import TxsTable from '~/routes/safe/components/Transactions/TxsTable'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
import { type Owner } from '~/routes/safe/store/models/owner'
type Props = {
safeAddress: string,
threshold: number,
transactions: List<Transaction>,
transactions: List<Transaction | IncomingTransaction>,
owners: List<Owner>,
userAddress: string,
granted: boolean,
createTransaction: Function,
processTransaction: Function,
fetchTransactions: Function,
currentNetwork: string,
nonce: number,
}
const TIMEOUT = 5000
const Transactions = ({
transactions = List(),
owners,
@ -30,36 +28,21 @@ const Transactions = ({
safeAddress,
createTransaction,
processTransaction,
fetchTransactions,
currentNetwork,
nonce,
}: Props) => {
let intervalId: IntervalID
useEffect(() => {
fetchTransactions(safeAddress)
intervalId = setInterval(() => {
fetchTransactions(safeAddress)
}, TIMEOUT)
return () => clearInterval(intervalId)
}, [safeAddress])
return (
<TxsTable
transactions={transactions}
threshold={threshold}
owners={owners}
userAddress={userAddress}
currentNetwork={currentNetwork}
granted={granted}
safeAddress={safeAddress}
createTransaction={createTransaction}
processTransaction={processTransaction}
nonce={nonce}
/>
)
}
}: Props) => (
<TxsTable
transactions={transactions}
threshold={threshold}
owners={owners}
userAddress={userAddress}
currentNetwork={currentNetwork}
granted={granted}
safeAddress={safeAddress}
createTransaction={createTransaction}
processTransaction={processTransaction}
nonce={nonce}
/>
)
export default Transactions

View File

@ -99,11 +99,13 @@ class SafeView extends React.Component<Props, State> {
activeTokens,
fetchTokenBalances,
fetchEtherBalance,
fetchTransactions,
checkAndUpdateSafeOwners,
} = this.props
checkAndUpdateSafeOwners(safeUrl)
fetchTokenBalances(safeUrl, activeTokens)
fetchEtherBalance(safeUrl)
fetchTransactions(safeUrl)
}
render() {
@ -119,7 +121,6 @@ class SafeView extends React.Component<Props, State> {
tokens,
createTransaction,
processTransaction,
fetchTransactions,
activateTokensByBalance,
fetchTokens,
updateSafe,
@ -139,7 +140,6 @@ class SafeView extends React.Component<Props, State> {
granted={granted}
createTransaction={createTransaction}
processTransaction={processTransaction}
fetchTransactions={fetchTransactions}
activateTokensByBalance={activateTokensByBalance}
fetchTokens={fetchTokens}
updateSafe={updateSafe}

View File

@ -6,6 +6,8 @@ import {
safeActiveTokensSelector,
safeBalancesSelector,
safeBlacklistedTokensSelector,
safeTransactionsSelector,
safeIncomingTransactionsSelector,
type RouterProps,
type SafeSelectorProps,
} from '~/routes/safe/store/selectors'
@ -14,12 +16,12 @@ import { type Safe } from '~/routes/safe/store/models/safe'
import { type Owner } from '~/routes/safe/store/models/owner'
import { type GlobalState } from '~/store'
import { sameAddress } from '~/logic/wallets/ethAddresses'
import { safeTransactionsSelector } from '~/routes/safe/store/selectors/index'
import { orderedTokenListSelector, tokensSelector } from '~/logic/tokens/store/selectors'
import { type Token } from '~/logic/tokens/store/model/token'
import { type Transaction, type TransactionStatus } from '~/routes/safe/store/models/transaction'
import { safeParamAddressSelector } from '../store/selectors'
import { getEthAsToken } from '~/logic/tokens/utils/tokenHelpers'
import type { IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
export type SelectorProps = {
safe: SafeSelectorProps,
@ -30,7 +32,7 @@ export type SelectorProps = {
userAddress: string,
network: string,
safeUrl: string,
transactions: List<Transaction>,
transactions: List<Transaction | IncomingTransaction>,
}
const getTxStatus = (tx: Transaction, userAddress: string, safe: Safe): TransactionStatus => {
@ -111,11 +113,12 @@ const extendedSafeTokensSelector: Selector<GlobalState, RouterProps, List<Token>
},
)
const extendedTransactionsSelector: Selector<GlobalState, RouterProps, List<Transaction>> = createSelector(
const extendedTransactionsSelector: Selector<GlobalState, RouterProps, List<Transaction | IncomingTransaction>> = createSelector(
safeSelector,
userAccountSelector,
safeTransactionsSelector,
(safe, userAddress, transactions) => {
safeIncomingTransactionsSelector,
(safe, userAddress, transactions, incomingTransactions) => {
const extendedTransactions = transactions.map((tx: Transaction) => {
let extendedTx = tx
@ -136,7 +139,7 @@ const extendedTransactionsSelector: Selector<GlobalState, RouterProps, List<Tran
return extendedTx.set('status', getTxStatus(extendedTx, userAddress, safe))
})
return extendedTransactions
return List([...extendedTransactions, ...incomingTransactions])
},
)

View File

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

View File

@ -1,21 +1,28 @@
// @flow
import { List, Map } from 'immutable'
import axios from 'axios'
import bn from 'bignumber.js'
import type { Dispatch as ReduxDispatch } from 'redux'
import { type GlobalState } from '~/store/index'
import { makeOwner } from '~/routes/safe/store/models/owner'
import { makeTransaction, type Transaction } from '~/routes/safe/store/models/transaction'
import { makeIncomingTransaction, type IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
import { makeConfirmation } from '~/routes/safe/store/models/confirmation'
import { buildTxServiceUrl, type TxServiceType } from '~/logic/safe/transactions/txHistory'
import { buildIncomingTxServiceUrl } from '~/logic/safe/transactions/incomingTxHistory'
import { getOwners } from '~/logic/safe/utils'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { addTransactions } from './addTransactions'
import { addIncomingTransactions } from './addIncomingTransactions'
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 { ZERO_ADDRESS } from '~/logic/wallets/ethAddresses'
import enqueueSnackbar from '~/logic/notifications/store/actions/enqueueSnackbar'
import { enhanceSnackbarForAction, SUCCESS } from '~/logic/notifications'
import { getIncomingTxAmount } from '~/routes/safe/components/Transactions/TxsTable/columns'
let web3
@ -32,6 +39,7 @@ type TxServiceModel = {
data: string,
operation: number,
nonce: number,
blockNumber: number,
safeTxGas: number,
baseGas: number,
gasPrice: number,
@ -46,6 +54,15 @@ type TxServiceModel = {
transactionHash: string,
}
type IncomingTxServiceModel = {
blockNumber: number,
transactionHash: string,
to: string,
value: number,
tokenAddress: string,
from: string,
}
export const buildTransactionFrom = async (
safeAddress: string,
tx: TxServiceModel,
@ -122,6 +139,7 @@ export const buildTransactionFrom = async (
return makeTransaction({
symbol,
nonce: tx.nonce,
blockNumber: tx.blockNumber,
value: tx.value.toString(),
confirmations,
decimals,
@ -172,6 +190,43 @@ const addMockSafeCreationTx = (safeAddress) => [{
creationTx: true,
}]
export const buildIncomingTransactionFrom = async (tx: IncomingTxServiceModel) => {
let symbol = 'ETH'
let decimals = 18
const whenExecutionDate = web3.eth.getBlock(tx.blockNumber)
.then(({ timestamp }) => new Date(timestamp * 1000).toISOString())
const whenFee = web3.eth.getTransaction(tx.transactionHash).then((t) => bn(t.gas).div(t.gasPrice).toFixed())
const [executionDate, fee] = await Promise.all([whenExecutionDate, whenFee])
if (tx.tokenAddress) {
try {
const tokenContract = await getHumanFriendlyToken()
const tokenInstance = await tokenContract.at(tx.tokenAddress)
const [tokenSymbol, tokenDecimals] = await Promise.all([tokenInstance.symbol(), tokenInstance.decimals()])
symbol = tokenSymbol
decimals = tokenDecimals
} catch (err) {
const { methods } = new web3.eth.Contract(ALTERNATIVE_TOKEN_ABI, tx.to)
const [tokenSymbol, tokenDecimals] = await Promise.all([methods.symbol, methods.decimals].map((m) => m().call()))
symbol = web3.utils.toAscii(tokenSymbol)
decimals = tokenDecimals
}
}
const { transactionHash, ...incomingTx } = tx
return makeIncomingTransaction({
...incomingTx,
symbol,
decimals,
fee,
executionDate,
executionTxHash: transactionHash,
safeTxHash: transactionHash,
})
}
export const loadSafeTransactions = async (safeAddress: string) => {
web3 = await getWeb3()
@ -189,10 +244,23 @@ export const loadSafeTransactions = async (safeAddress: string) => {
const txsRecord = await Promise.all(
transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx)),
)
return Map().set(safeAddress, List(txsRecord))
}
export const loadSafeIncomingTransactions = async (safeAddress: string) => {
const url = buildIncomingTxServiceUrl(safeAddress)
const response = await axios.get(url)
const incomingTransactions: IncomingTxServiceModel[] = response.data.results
const incomingTxsRecord = await Promise.all(incomingTransactions.map(buildIncomingTransactionFrom))
return Map().set(safeAddress, List(incomingTxsRecord))
}
export default (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
const transactions: Map<string, List<Transaction>> = await loadSafeTransactions(safeAddress)
return dispatch(addTransactions(transactions))
const incomingTransactions: Map<string, List<IncomingTransaction>> = await loadSafeIncomingTransactions(safeAddress)
dispatch(addTransactions(transactions))
dispatch(addIncomingTransactions(incomingTransactions))
}

View File

@ -1,16 +1,24 @@
// @flow
import type { AnyAction, Store } from 'redux'
import { push } from 'connected-react-router'
import { Map } from 'immutable'
import { type GlobalState } from '~/store/'
import { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/addTransactions'
import { ADD_INCOMING_TRANSACTIONS } from '~/routes/safe/store/actions/addIncomingTransactions'
import { getAwaitingTransactions } from '~/logic/safe/transactions/awaitingTransactions'
import { userAccountSelector } from '~/logic/wallets/store/selectors'
import enqueueSnackbar from '~/logic/notifications/store/actions/enqueueSnackbar'
import { enhanceSnackbarForAction, NOTIFICATIONS } from '~/logic/notifications'
import { enhanceSnackbarForAction, NOTIFICATIONS, SUCCESS } from '~/logic/notifications'
import closeSnackbarAction from '~/logic/notifications/store/actions/closeSnackbar'
import { getIncomingTxAmount } from '~/routes/safe/components/Transactions/TxsTable/columns'
import updateSafe from '~/routes/safe/store/actions/updateSafe'
import { loadFromStorage } from '~/utils/storage'
import { SAFES_KEY } from '~/logic/safe/utils'
import { RECURRING_USER_KEY } from '~/utils/verifyRecurringUser'
const watchedActions = [
ADD_TRANSACTIONS,
ADD_INCOMING_TRANSACTIONS,
]
const notificationsMiddleware = (store: Store<GlobalState>) => (next: Function) => async (action: AnyAction) => {
@ -39,6 +47,50 @@ const notificationsMiddleware = (store: Store<GlobalState>) => (next: Function)
})
break
}
case ADD_INCOMING_TRANSACTIONS: {
action.payload.forEach(async (incomingTransactions, safeAddress) => {
const storedSafes = await loadFromStorage(SAFES_KEY)
const latestIncomingTxBlock = storedSafes ? storedSafes[safeAddress].latestIncomingTxBlock : 0
const newIncomingTransactions = incomingTransactions.filter((tx) => tx.blockNumber > latestIncomingTxBlock)
const { message, ...TX_INCOMING_MSG } = NOTIFICATIONS.TX_INCOMING_MSG
const recurringUser = await loadFromStorage(RECURRING_USER_KEY)
if (recurringUser) {
if (newIncomingTransactions.size > 3) {
dispatch(
enqueueSnackbar(
enhanceSnackbarForAction({
...TX_INCOMING_MSG,
message: 'Multiple incoming transfers'
})
)
)
} else {
newIncomingTransactions.forEach((tx) => {
dispatch(
enqueueSnackbar(
enhanceSnackbarForAction({
...TX_INCOMING_MSG,
message: `${message}${getIncomingTxAmount(tx)}`,
}),
),
)
})
}
}
dispatch(
updateSafe({
address: safeAddress,
latestIncomingTxBlock: newIncomingTransactions.size
? newIncomingTransactions.last().blockNumber
: latestIncomingTxBlock,
}),
)
})
break
}
default:
break
}

View File

@ -0,0 +1,39 @@
// @flow
import { Record } from 'immutable'
import type { RecordFactory, RecordOf } from 'immutable'
export const INCOMING_TX_TYPE = 'incoming'
export type IncomingTransactionProps = {
blockNumber: number,
executionTxHash: string,
safeTxHash: string,
to: string,
value: number,
tokenAddress: string,
from: string,
symbol: string,
decimals: number,
fee: string,
executionDate: string,
type: string,
status: string,
}
export const makeIncomingTransaction: RecordFactory<IncomingTransactionProps> = Record({
blockNumber: 0,
executionTxHash: '',
safeTxHash: '',
to: '',
value: 0,
tokenAddress: '',
from: '',
symbol: '',
decimals: 18,
fee: '',
executionDate: '',
type: INCOMING_TX_TYPE,
status: 'success',
})
export type IncomingTransaction = RecordOf<IncomingTransactionProps>

View File

@ -15,6 +15,7 @@ export type SafeProps = {
blacklistedTokens: Set<string>,
ethBalance?: string,
nonce: number,
latestIncomingTxBlock: number,
}
const SafeRecord: RecordFactory<SafeProps> = Record({
@ -27,6 +28,7 @@ const SafeRecord: RecordFactory<SafeProps> = Record({
blacklistedTokens: new Set(),
balances: Map({}),
nonce: 0,
latestIncomingTxBlock: 0,
})
export type Safe = RecordOf<SafeProps>

View File

@ -4,6 +4,8 @@ import type { RecordFactory, RecordOf } from 'immutable'
import { type Confirmation } from '~/routes/safe/store/models/confirmation'
import { ZERO_ADDRESS } from '~/logic/wallets/ethAddresses'
export const OUTGOING_TX_TYPE = 'outgoing'
export type TransactionType = 'incoming' | 'outgoing' | 'settings' | 'custom' | 'creation' | 'cancellation'
export type TransactionStatus =
@ -16,6 +18,7 @@ export type TransactionStatus =
export type TransactionProps = {
nonce: number,
blockNumber: number,
value: string,
confirmations: List<Confirmation>,
recipient: string,
@ -43,10 +46,12 @@ export type TransactionProps = {
isTokenTransfer: boolean,
decodedParams?: Object,
refundParams?: Object,
type: string,
}
export const makeTransaction: RecordFactory<TransactionProps> = Record({
nonce: 0,
blockNumber: 0,
value: 0,
confirmations: List([]),
recipient: '',
@ -74,6 +79,7 @@ export const makeTransaction: RecordFactory<TransactionProps> = Record({
isTokenTransfer: false,
decodedParams: {},
refundParams: null,
type: 'outgoing',
})
export type Transaction = RecordOf<TransactionProps>

View File

@ -0,0 +1,16 @@
// @flow
import { List, Map } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions'
import { ADD_INCOMING_TRANSACTIONS } from '~/routes/safe/store/actions/addIncomingTransactions'
import type { IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
export const INCOMING_TRANSACTIONS_REDUCER_ID = 'incomingTransactions'
export type IncomingState = Map<string, List<IncomingTransaction>>
export default handleActions<IncomingState, *>(
{
[ADD_INCOMING_TRANSACTIONS]: (state: IncomingState, action: ActionType<Function>): IncomingState => action.payload,
},
Map(),
)

View File

@ -6,9 +6,14 @@ import { type GlobalState } from '~/store/index'
import { SAFE_PARAM_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 IncomingState as IncomingTransactionsState,
INCOMING_TRANSACTIONS_REDUCER_ID
} from '~/routes/safe/store/reducer/incomingTransactions'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Confirmation } from '~/routes/safe/store/models/confirmation'
import { SAFE_REDUCER_ID } from '~/routes/safe/store/reducer/safe'
import type { IncomingTransaction } from '~/routes/safe/store/models/incomingTransaction'
export type RouterProps = {
match: Match,
@ -43,6 +48,8 @@ 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 oneTransactionSelector = (state: GlobalState, props: TransactionProps) => props.transaction
export const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || ''
@ -63,6 +70,22 @@ export const safeTransactionsSelector: Selector<GlobalState, RouterProps, List<T
},
)
export const safeIncomingTransactionsSelector: Selector<GlobalState, RouterProps, List<IncomingTransaction>> = createSelector(
incomingTransactionsSelector,
safeParamAddressSelector,
(incomingTransactions: IncomingTransactionsState, address: string): List<IncomingTransaction> => {
if (!incomingTransactions) {
return List([])
}
if (!address) {
return List([])
}
return incomingTransactions.get(address) || List([])
}
)
export const confirmationsTransactionSelector: Selector<GlobalState, TransactionProps, number> = createSelector(
oneTransactionSelector,
(tx: Transaction) => {

View File

@ -12,6 +12,10 @@ import transactions, {
type State as TransactionsState,
TRANSACTIONS_REDUCER_ID,
} from '~/routes/safe/store/reducer/transactions'
import incomingTransactions, {
type IncomingState as IncomingTransactionsState,
INCOMING_TRANSACTIONS_REDUCER_ID,
} from '~/routes/safe/store/reducer/incomingTransactions'
import provider, { PROVIDER_REDUCER_ID, type State as ProviderState } from '~/logic/wallets/store/reducer/provider'
import tokens, { TOKEN_REDUCER_ID, type State as TokensState } from '~/logic/tokens/store/reducer/tokens'
import notifications, {
@ -35,6 +39,7 @@ export type GlobalState = {
safes: SafeState,
tokens: TokensState,
transactions: TransactionsState,
incomingTransactions: IncomingTransactionsState,
notifications: NotificationsState,
}
@ -46,6 +51,7 @@ const reducers: Reducer<GlobalState> = combineReducers({
[SAFE_REDUCER_ID]: safe,
[TOKEN_REDUCER_ID]: tokens,
[TRANSACTIONS_REDUCER_ID]: transactions,
[INCOMING_TRANSACTIONS_REDUCER_ID]: incomingTransactions,
[NOTIFICATIONS_REDUCER_ID]: notifications,
[COOKIES_REDUCER_ID]: cookies,
})

View File

@ -0,0 +1,18 @@
// @flow
import { loadFromStorage, saveToStorage } from '~/utils/storage'
export const RECURRING_USER_KEY = 'RECURRING_USER'
const verifyRecurringUser = async () => {
const recurringUser = await loadFromStorage(RECURRING_USER_KEY)
if (recurringUser === undefined) {
await saveToStorage(RECURRING_USER_KEY, false)
}
if (recurringUser === false) {
await saveToStorage(RECURRING_USER_KEY, true)
}
}
export default verifyRecurringUser

View File

@ -13112,6 +13112,15 @@ query-string@^5.0.1:
object-assign "^4.1.0"
strict-uri-encode "^1.0.0"
query-string@^6.9.0:
version "6.9.0"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.9.0.tgz#1c3b727c370cf00f177c99f328fda2108f8fa3dd"
integrity sha512-KG4bhCFYapExLsUHrFt+kQVEegF2agm4cpF/VNc6pZVthIfCc/GK8t8VyNIE3nyXG9DK3Tf2EGkxjR6/uRdYsA==
dependencies:
decode-uri-component "^0.2.0"
split-on-first "^1.0.0"
strict-uri-encode "^2.0.0"
querystring-es3@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@ -14748,6 +14757,11 @@ spdy@^4.0.1:
select-hose "^2.0.0"
spdy-transport "^3.0.0"
split-on-first@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@ -14914,6 +14928,11 @@ strict-uri-encode@^1.0.0:
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY=
string-length@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"
@ -17615,7 +17634,6 @@ websocket@1.0.29, "websocket@github:web3-js/WebSocket-Node#polyfill/globalThis":
dependencies:
debug "^2.2.0"
es5-ext "^0.10.50"
gulp "^4.0.2"
nan "^2.14.0"
typedarray-to-buffer "^3.1.5"
yaeti "^0.0.6"