diff --git a/app/actions/pendingTransactionActions.js b/app/actions/pendingTransactionActions.js index 88ffcfd40..9b6767b4b 100644 --- a/app/actions/pendingTransactionActions.js +++ b/app/actions/pendingTransactionActions.js @@ -3,24 +3,30 @@ import { createActions } from 'spunky' import Neon from '@cityofzion/neon-js' import { isEmpty } from 'lodash-es' +import { toBigNumber } from '../core/math' import { getStorage, setStorage } from '../core/storage' import { getNode, getRPCEndpoint } from './nodeStorageActions' -import { findAndReturnTokenInfo } from '../util/findAndReturnTokenInfo' +import { + findAndReturnTokenInfo, + getImageBySymbol, +} from '../util/findAndReturnTokenInfo' export const ID = 'pendingTransactions' const STORAGE_KEY = 'pendingTransactions' -const MINIMUM_CONFIRMATIONS = 40 +const MINIMUM_CONFIRMATIONS = 10 type PendingTransactions = { - [address: string]: Array, + [address: string]: Array, } type PendingTransaction = { vout: Array<{ asset: string, address: string, value: string }>, + sendEntries: Array, confirmations: number, txid: number, net_fee: string, blocktime: number, + type: string, } type ParsedPendingTransaction = { @@ -30,10 +36,66 @@ type ParsedPendingTransaction = { blocktime: number, to: string, amount: string, - asset: string, + asset: { + symbol: string, + image: string, + }, +} + +export const parseContractTransaction = async ( + transaction: PendingTransaction, + net: string, +): Promise> => { + const parsedData = [] + // eslint-disable-next-line camelcase + const { confirmations, txid, net_fee, blocktime = 0 } = transaction + transaction.vout.pop() + // eslint-disable-next-line + for (const send of transaction.vout) { + parsedData.push({ + confirmations, + txid, + net_fee, + blocktime, + amount: toBigNumber(send.value).toString(), + to: send.address, + // eslint-disable-next-line no-await-in-loop + asset: await findAndReturnTokenInfo(send.asset, net), + }) + } + return parsedData +} + +export const parseInvocationTransaction = ( + transaction: PendingTransaction, +): Array => { + const { + confirmations, + txid, + // eslint-disable-next-line camelcase + net_fee, + blocktime = 0, + sendEntries, + } = transaction + + // things get tricky during invocation transactions as there is no vout array + // and it is not straight forward parsing the produced script. Instead we + // use the original send entries array. + return sendEntries.map(send => ({ + confirmations, + txid, + net_fee, + blocktime, + amount: toBigNumber(send.amount).toString(), + to: send.address, + asset: { + symbol: send.symbol, + image: getImageBySymbol(send.symbol), + }, + })) } -export const parsePendingTxInfo = async ( +export const parsePendingContractTxInfo = async ( pendingTransactionsInfo: Array, net: string, ) => { @@ -41,21 +103,11 @@ export const parsePendingTxInfo = async ( // eslint-disable-next-line for (const transaction of pendingTransactionsInfo) { if (transaction) { - // eslint-disable-next-line - const { confirmations, txid, net_fee, blocktime = 0 } = transaction - transaction.vout.pop() - // eslint-disable-next-line - for (const send of transaction.vout) { - parsedData.push({ - confirmations, - txid, - net_fee, - blocktime, - amount: send.value, - to: send.address, - // eslint-disable-next-line no-await-in-loop - asset: await findAndReturnTokenInfo(send.asset, net), - }) + if (transaction.type === 'InvocationTransaction') { + parsedData.push(...parseInvocationTransaction(transaction)) + } else { + // eslint-disable-next-line no-await-in-loop + parsedData.push(...(await parseContractTransaction(transaction, net))) } } } @@ -83,7 +135,7 @@ export const pruneConfirmedOrStaleTransaction = async ( const storage = await getPendingTransactions() if (Array.isArray(storage[address])) { storage[address] = storage[address].filter( - transaction => transaction !== txId, + transaction => transaction.hash !== txId, ) } await setPendingTransactions(storage) @@ -91,13 +143,13 @@ export const pruneConfirmedOrStaleTransaction = async ( export const addPendingTransaction = createActions( ID, - ({ address, txId }) => async (): Promise => { + ({ address, tx }) => async (): Promise => { const transactions = await getPendingTransactions() if (Array.isArray(transactions[address])) { - transactions[address].push(txId) + transactions[address].push(tx) } else { - transactions[address] = [] + transactions[address] = [tx] } await setPendingTransactions(transactions) }, @@ -121,24 +173,34 @@ export const getPendingTransactionInfo = createActions( if (transaction) { // eslint-disable-next-line const result = await client - .getRawTransaction(transaction, 1) + .getRawTransaction(transaction.hash, 1) .catch(async e => { console.error( e, - 'An transaction was added to storage that the blockchain does not recognize - purging from storage', + `Error performing getRawTransaction for txid: ${ + transaction.hash + }`, ) - await pruneConfirmedOrStaleTransaction(address, transaction) + if (e.message === 'Unknown transaction') { + await pruneConfirmedOrStaleTransaction( + address, + transaction.hash, + ) + } }) - if (result.confirmations > MINIMUM_CONFIRMATIONS) { - // eslint-disable-next-line - await pruneConfirmedOrStaleTransaction(address, transaction) + if (result) { + if (result.confirmations > MINIMUM_CONFIRMATIONS) { + // eslint-disable-next-line + await pruneConfirmedOrStaleTransaction(address, transaction.hash) + } else { + pendingTransactionInfo.push({ ...result, ...transaction }) + } } - pendingTransactionInfo.push(result) } } - return parsePendingTxInfo(pendingTransactionInfo, net) + return parsePendingContractTxInfo(pendingTransactionInfo, net) } return [] }, diff --git a/app/components/Blockchain/Transaction/ClaimAbstract.jsx b/app/components/Blockchain/Transaction/ClaimAbstract.jsx new file mode 100644 index 000000000..2eb9cca4a --- /dev/null +++ b/app/components/Blockchain/Transaction/ClaimAbstract.jsx @@ -0,0 +1,61 @@ +// @flow +import React, { Fragment } from 'react' + +import classNames from 'classnames' +import styles from './Transaction.scss' +import ClaimIcon from '../../../assets/icons/claim.svg' +import CopyToClipboard from '../../CopyToClipboard' + +type Props = { + txDate: React$Node, + logo: React$Node, + label: string, + amount: string | number, + contactTo: React$Node | string, + to: string, + contactToExists: boolean, +} + +export default class ClaimAbstract extends React.Component { + render = () => { + const { + txDate, + logo, + label, + amount, + contactTo, + to, + contactToExists, + } = this.props + return ( +
+
+
+
+ +
+
+ {txDate} +
+ {logo} + {label} +
+
{amount}
+
+ + {contactTo} + {!contactToExists && ( + + )} + +
+
+
+
+ ) + } +} diff --git a/app/components/Blockchain/Transaction/PendingTransaction.jsx b/app/components/Blockchain/Transaction/PendingAbstract.jsx similarity index 87% rename from app/components/Blockchain/Transaction/PendingTransaction.jsx rename to app/components/Blockchain/Transaction/PendingAbstract.jsx index 87cc18b21..2da384eb4 100644 --- a/app/components/Blockchain/Transaction/PendingTransaction.jsx +++ b/app/components/Blockchain/Transaction/PendingAbstract.jsx @@ -9,20 +9,20 @@ import CopyToClipboard from '../../CopyToClipboard' import { pluralize } from '../../../util/pluralize' type Props = { - renderTxDate: (time: ?number) => React$Node | null, + txDate: React$Node | null, findContact: (address: string) => React$Node | null, asset: { symbol: string, image?: string, }, blocktime: number, - amount: string, + amount: string | number, to: string, confirmations: number, showAddContactModal: (address: string) => void, } -export default class Transaction extends React.Component { +export default class PendingAbstract extends React.Component { render = () => { const { asset, @@ -32,6 +32,7 @@ export default class Transaction extends React.Component { findContact, showAddContactModal, confirmations, + txDate, } = this.props const contactTo = findContact(to) const contactToExists = contactTo !== to @@ -47,7 +48,14 @@ export default class Transaction extends React.Component {
- {!!blocktime && this.props.renderTxDate(blocktime)} + {!blocktime ? ( +
+ awaiting confirmations... +
+ ) : ( + txDate + )} +
{logo} {asset.symbol} diff --git a/app/components/Blockchain/Transaction/ReceiveAbstract.jsx b/app/components/Blockchain/Transaction/ReceiveAbstract.jsx new file mode 100644 index 000000000..b527fa381 --- /dev/null +++ b/app/components/Blockchain/Transaction/ReceiveAbstract.jsx @@ -0,0 +1,80 @@ +// @flow +import React from 'react' +import classNames from 'classnames' + +import Button from '../../Button' +import styles from './Transaction.scss' +import ReceiveIcon from '../../../assets/icons/receive-tx.svg' +import ContactsAdd from '../../../assets/icons/contacts-add.svg' +import CopyToClipboard from '../../CopyToClipboard' + +type Props = { + txDate: React$Node, + logo: React$Node, + label: string, + amount: string | number, + showAddContactModal: (from: string) => void, + contactFromExists: boolean, + from: string, + address: string, + contactFrom: React$Node | string, + contactFromExists: boolean, +} + +export default class ReceiveAbstract extends React.Component { + render = () => { + const { + txDate, + logo, + label, + amount, + contactFrom, + showAddContactModal, + contactFromExists, + from, + address, + } = this.props + const isMintTokens = from === 'MINT TOKENS' + const isGasClaim = address === from && !Number(amount) + return ( +
+
+
+
+ +
+
+ {txDate} +
+ {logo} + {label} +
+
{amount}
+
+ {contactFrom} + {!contactFromExists && + !isMintTokens && ( + + )} +
+ {isMintTokens || isGasClaim ? ( +
+ ) : ( + + )} +
+
+ ) + } +} diff --git a/app/components/Blockchain/Transaction/SendAbstract.jsx b/app/components/Blockchain/Transaction/SendAbstract.jsx new file mode 100644 index 000000000..1ffff3058 --- /dev/null +++ b/app/components/Blockchain/Transaction/SendAbstract.jsx @@ -0,0 +1,83 @@ +// @flow +import React, { Fragment } from 'react' +import classNames from 'classnames' + +import Button from '../../Button' +import styles from './Transaction.scss' +import SendIcon from '../../../assets/icons/send-tx.svg' +import ContactsAdd from '../../../assets/icons/contacts-add.svg' +import CopyToClipboard from '../../CopyToClipboard' + +type Props = { + txDate: React$Node, + logo: React$Node, + label: string, + amount: string | number, + contactTo: React$Node | string, + to: string, + + contactToExists: boolean, + showAddContactModal: (to: string) => void, + isNetworkFee: boolean, +} + +export default class SendAbstract extends React.Component { + render = () => { + const { + txDate, + logo, + label, + amount, + contactTo, + to, + contactToExists, + showAddContactModal, + isNetworkFee, + } = this.props + return ( +
+
+
+
+ +
+
+ {txDate} +
+ {logo} + {label} +
+
{amount}
+
+ {isNetworkFee ? ( + to + ) : ( + + {contactTo} + {!contactToExists && ( + + )} + + )} +
+ {isNetworkFee ? ( +
+ ) : ( + + )} +
+
+ ) + } +} diff --git a/app/components/Blockchain/Transaction/Transaction.jsx b/app/components/Blockchain/Transaction/Transaction.jsx index 4c2441b2d..bcfc90180 100644 --- a/app/components/Blockchain/Transaction/Transaction.jsx +++ b/app/components/Blockchain/Transaction/Transaction.jsx @@ -1,5 +1,5 @@ // @flow -import React, { Fragment } from 'react' +import React from 'react' import type { Node } from 'react' import moment from 'moment' @@ -8,15 +8,13 @@ import classNames from 'classnames' import { TX_TYPES } from '../../../core/constants' import Button from '../../Button' -import PendingTransaction from './PendingTransaction' +import PendingAbstract from './PendingAbstract' +import ClaimAbstract from './ClaimAbstract' +import SendAbstract from './SendAbstract' +import ReceiveAbstract from './ReceiveAbstract' +import InfoIcon from '../../../assets/icons/info.svg' import { openExplorerTx } from '../../../core/explorer' import styles from './Transaction.scss' -import ClaimIcon from '../../../assets/icons/claim.svg' -import SendIcon from '../../../assets/icons/send-tx.svg' -import ReceiveIcon from '../../../assets/icons/receive-tx.svg' -import ContactsAdd from '../../../assets/icons/contacts-add.svg' -import InfoIcon from '../../../assets/icons/info.svg' -import CopyToClipboard from '../../CopyToClipboard' import Tooltip from '../../Tooltip' type Props = { @@ -53,30 +51,21 @@ export default class Transaction extends React.Component { } = this.props return (
- {isPending ? ( - - ) : ( - - {this.renderAbstract(type)} - - + {this.renderAbstract(type)} + {!isPending && ( + )}
) } - findContact = (address: string): Node => { + findContact = (address: string): Node | string => { const { contacts } = this.props if (contacts && !isEmpty(contacts)) { const label = contacts[address] @@ -95,7 +84,7 @@ export default class Transaction extends React.Component { this.props.showAddContactModal({ address }) } - handleClick = () => { + handleViewTransaction = () => { const { networkId, explorer, tx } = this.props const { txid } = tx openExplorerTx(networkId, explorer, txid) @@ -114,136 +103,43 @@ export default class Transaction extends React.Component { } renderAbstract = (type: string) => { + const { isPending, address } = this.props const { time, label, amount, isNetworkFee, to, from, image } = this.props.tx - const contactTo = this.findContact(to) + const contactFrom = from && this.findContact(from) const contactToExists = contactTo !== to + const contactFromExists = contactFrom !== from const logo = image && {`${label}`} const txDate = this.renderTxDate(time) + const abstractProps = { + txDate, + logo, + contactTo, + amount, + contactFrom, + contactToExists, + findContact: this.findContact, + showAddContactModal: this.displayModal, + isNetworkFee, + contactFromExists, + from, + address, + ...this.props.tx, + } + + if (isPending) { + return + } + switch (type) { case TX_TYPES.CLAIM: - return ( -
-
-
- -
-
- {txDate} -
- {logo} - {label} -
-
{amount}
-
- - {contactTo} - {!contactToExists && ( - - )} - -
-
-
- ) + return case TX_TYPES.SEND: - return ( -
-
-
- -
-
- {txDate} -
- {logo} - {label} -
-
{amount}
-
- {isNetworkFee ? ( - to - ) : ( - - {contactTo} - {!contactToExists && ( - - )} - - )} -
- {isNetworkFee ? ( -
- ) : ( - - )} -
- ) + return case TX_TYPES.RECEIVE: { - if (!from) { - // shouldn't happen but for flow's sake - return null - } - const contactFrom = this.findContact(from) - const contactFromExists = contactFrom !== from - const isMintTokens = from === 'MINT TOKENS' - const isGasClaim = this.props.address === from && !Number(amount) - return ( -
-
-
- -
-
- {txDate} -
- {logo} - {label} -
-
{amount}
-
- {contactFrom} - {!contactFromExists && - !isMintTokens && ( - - )} -
- {isMintTokens || isGasClaim ? ( -
- ) : ( - - )} -
- ) + return } - default: console.warn('renderTxTypeIcon() invoked with an invalid argument!', { type, diff --git a/app/components/Blockchain/Transaction/Transaction.scss b/app/components/Blockchain/Transaction/Transaction.scss index 98b3d02f2..fd524d1bb 100644 --- a/app/components/Blockchain/Transaction/Transaction.scss +++ b/app/components/Blockchain/Transaction/Transaction.scss @@ -13,6 +13,13 @@ font-family: var(--font-gotham-book); } +.pendingTxDate { + @extend .txDateContainer; + font-style: italic; + font-size: 12px; + text-transform: uppercase; +} + .txLabelContainer { font-family: var(--font-gotham-book); font-size: 14px; @@ -67,11 +74,11 @@ .confirmationsContainer { max-width: 90px; + min-width: 90px; text-align: center; font-size: 12px; display: flex; flex-direction: column; - // justify-content: center; } .transactionContainer { diff --git a/app/containers/Send/index.js b/app/containers/Send/index.js index ada80cc48..e00a36bfe 100644 --- a/app/containers/Send/index.js +++ b/app/containers/Send/index.js @@ -22,14 +22,11 @@ import withSuccessNotification from '../../hocs/withSuccessNotification' import withFailureNotification from '../../hocs/withFailureNotification' import { MODAL_TYPES } from '../../core/constants' import withTokensData from '../../hocs/withTokensData' -import { addPendingTransaction } from '../../actions/pendingTransactionActions' const mapDispatchToProps = (dispatch: Function) => bindActionCreators( { sendTransaction, - addPendingTransaction: ({ address, txId }) => - addPendingTransaction.call({ address, txId }), showSendModal: props => dispatch(showModal(MODAL_TYPES.SEND, props)), }, dispatch, diff --git a/app/modules/transactions.js b/app/modules/transactions.js index 2cd0e26be..6c6c60985 100644 --- a/app/modules/transactions.js +++ b/app/modules/transactions.js @@ -1,7 +1,7 @@ // @flow /* eslint-disable camelcase */ import { api, sc, u, wallet, settings } from '@cityofzion/neon-js' -import { flatMap, keyBy, isEmpty } from 'lodash-es' +import { flatMap, keyBy, isEmpty, get } from 'lodash-es' import { showErrorNotification, @@ -76,21 +76,18 @@ const makeRequest = ( config: Object, script: string, ) => { + // NOTE: We purposefully mutate the contents of config + // because neon-js will also mutate this same object by reference + // eslint-disable-next-line no-param-reassign + config.intents = buildIntents(sendEntries) if (script === '') { - return api.sendAsset( - { ...config, intents: buildIntents(sendEntries) }, - api.neoscan, - ) + return api.sendAsset(config, api.neoscan) } - return api.doInvoke( - { - ...config, - intents: buildIntents(sendEntries), - script, - gas: 0, - }, - api.neoscan, - ) + // eslint-disable-next-line no-param-reassign + config.script = script + // eslint-disable-next-line no-param-reassign + config.gas = 0 + return api.doInvoke(config, api.neoscan) } export const generateBalanceInfo = ( @@ -105,37 +102,6 @@ export const generateBalanceInfo = ( }) } -export const buildTxAndAddPendingHash = async ( - script: string, - sendEntries: Array, - config: Object, - dispatch: DispatchType, -) => { - let configWithTransaction - if (script) { - configWithTransaction = await api.createTx( - { - ...config, - intents: buildIntents(sendEntries), - script, - gas: 0, - }, - 'invocation', - ) - } - configWithTransaction = await api.createTx( - { - ...config, - intents: buildIntents(sendEntries), - }, - 'contract', - ) - const { tx } = configWithTransaction - dispatch( - addPendingTransaction.call({ address: config.address, txId: tx.hash }), - ) -} - export const sendTransaction = ({ sendEntries, fees, @@ -198,7 +164,6 @@ export const sendTransaction = ({ signingFunction: isHardwareSend ? signingFunction : null, fees, url, - balance: undefined, } const balanceResults = await api @@ -219,8 +184,6 @@ export const sendTransaction = ({ // $FlowFixMe config.tokensBalanceMap, ) - - await buildTxAndAddPendingHash(script, sendEntries, config, dispatch) const { response } = await makeRequest(sendEntries, config, script) if (!response.result) { @@ -233,11 +196,23 @@ export const sendTransaction = ({ 'Transaction complete! Your balance will automatically update when the blockchain has processed it.', }), ) - return resolve(response) } catch (err) { console.error({ err }) rejectTransaction(`Transaction failed: ${err.message}`) return reject(err) + } finally { + const outputs = get(config, 'tx.outputs') + const hash = get(config, 'tx.hash') + + dispatch( + addPendingTransaction.call({ + address: config.address, + tx: { + hash, + sendEntries, + }, + }), + ) } }) diff --git a/app/util/findAndReturnTokenInfo.js b/app/util/findAndReturnTokenInfo.js index 6380c7dbf..299147083 100644 --- a/app/util/findAndReturnTokenInfo.js +++ b/app/util/findAndReturnTokenInfo.js @@ -6,24 +6,25 @@ import { getDefaultTokens } from '../core/nep5' import { ASSETS, NEO_ID, GAS_ID } from '../core/constants' import { getRPCEndpoint } from '../actions/nodeStorageActions' +export const getImageBySymbol = (symbol: string) => imageMap[symbol] + export const findAndReturnTokenInfo = async ( scriptHash: string, net: string, ): Promise => { const tokens = await getDefaultTokens() const token = tokens.find(token => token.scriptHash.includes(scriptHash)) - const getImage = symbol => imageMap[symbol] if (token) return token if (scriptHash.includes(NEO_ID)) { return { symbol: ASSETS.NEO, - image: getImage(ASSETS.NEO), + image: getImageBySymbol(ASSETS.NEO), } } if (scriptHash.includes(GAS_ID)) { return { symbol: ASSETS.GAS, - image: getImage(ASSETS.GAS), + image: getImageBySymbol(ASSETS.GAS), } } const endpoint = await getRPCEndpoint(net)