diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index d23de5fa0..400633c8c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -17,6 +17,9 @@ "confirmClear": { "message": "Are you sure you want to clear approved websites?" }, + "contractInteraction": { + "message": "Contract Interaction" + }, "clearApprovalDataSuccess": { "message": "Approved website data cleared successfully." }, @@ -185,6 +188,9 @@ "cancellationGasFee": { "message": "Cancellation Gas Fee" }, + "cancelled": { + "message": "Cancelled" + }, "cancelN": { "message": "Cancel all $1 transactions" }, @@ -1177,6 +1183,12 @@ "speedUpSubtitle": { "message": "Increase your gas price to attempt to overwrite and speed up your transaction" }, + "speedUpCancellation": { + "message": "Speed up this cancellation" + }, + "speedUpTransaction": { + "message": "Speed up this transaction" + }, "status": { "message": "Status" }, @@ -1263,29 +1275,38 @@ "message": "transaction" }, "transactionConfirmed": { - "message": "Transaction confirmed on $2." + "message": "Transaction confirmed at $2." }, "transactionCreated": { - "message": "Transaction created with a value of $1 on $2." + "message": "Transaction created with a value of $1 at $2." }, "transactionWithNonce": { "message": "Transaction $1" }, "transactionDropped": { - "message": "Transaction dropped on $2." + "message": "Transaction dropped at $2." }, "transactionSubmitted": { - "message": "Transaction submitted on $2." + "message": "Transaction submitted with gas fee of $1 at $2." + }, + "transactionResubmitted": { + "message": "Transaction resubmitted with gas fee increased to $1 at $2" }, "transactionUpdated": { - "message": "Transaction updated on $2." + "message": "Transaction updated at $2." }, "transactionUpdatedGas": { - "message": "Transaction updated with a gas price of $1 on $2." + "message": "Transaction updated with a gas fee of $1 at $2." }, "transactionErrored": { "message": "Transaction encountered an error." }, + "transactionCancelAttempted": { + "message": "Transaction cancel attempted with gas fee of $1 at $2" + }, + "transactionCancelSuccess": { + "message": "Transaction successfully cancelled at $2" + }, "transactions": { "message": "transactions" }, @@ -1350,9 +1371,6 @@ "unknown": { "message": "Unknown" }, - "unknownFunction": { - "message": "Unknown Function" - }, "unknownNetwork": { "message": "Unknown Private Network" }, diff --git a/app/images/icons/cancelled.svg b/app/images/icons/cancelled.svg new file mode 100755 index 000000000..ae4846dde --- /dev/null +++ b/app/images/icons/cancelled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/images/icons/confirm.svg b/app/images/icons/confirm.svg new file mode 100644 index 000000000..3263bf03e --- /dev/null +++ b/app/images/icons/confirm.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/images/icons/error.svg b/app/images/icons/error.svg new file mode 100644 index 000000000..bf5abf946 --- /dev/null +++ b/app/images/icons/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/images/icons/new.svg b/app/images/icons/new.svg new file mode 100755 index 000000000..f56c43e08 --- /dev/null +++ b/app/images/icons/new.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/images/icons/retry.svg b/app/images/icons/retry.svg new file mode 100755 index 000000000..ddaa198ca --- /dev/null +++ b/app/images/icons/retry.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/images/icons/submitted.svg b/app/images/icons/submitted.svg new file mode 100755 index 000000000..b5ced8777 --- /dev/null +++ b/app/images/icons/submitted.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/scripts/controllers/transactions/enums.js b/app/scripts/controllers/transactions/enums.js index be6f16e0d..d41400b9f 100644 --- a/app/scripts/controllers/transactions/enums.js +++ b/app/scripts/controllers/transactions/enums.js @@ -3,10 +3,12 @@ const TRANSACTION_TYPE_RETRY = 'retry' const TRANSACTION_TYPE_STANDARD = 'standard' const TRANSACTION_STATUS_APPROVED = 'approved' +const TRANSACTION_STATUS_CONFIRMED = 'confirmed' module.exports = { TRANSACTION_TYPE_CANCEL, TRANSACTION_TYPE_RETRY, TRANSACTION_TYPE_STANDARD, TRANSACTION_STATUS_APPROVED, + TRANSACTION_STATUS_CONFIRMED, } diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index f530fbd22..2ce736beb 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -230,13 +230,15 @@ class TransactionController extends EventEmitter { to allow the user to resign the transaction with a higher gas values @param originalTxId {number} - the id of the txMeta that you want to attempt to retry + @param gasPrice {string=} - Optional gas price to be increased to use as the retry + transaction's gas price @return {txMeta} */ - async retryTransaction (originalTxId) { + async retryTransaction (originalTxId, gasPrice) { const originalTxMeta = this.txStateManager.getTx(originalTxId) const { txParams } = originalTxMeta - const lastGasPrice = originalTxMeta.txParams.gasPrice + const lastGasPrice = gasPrice || originalTxMeta.txParams.gasPrice const suggestedGasPriceBN = new ethUtil.BN(ethUtil.stripHexPrefix(this.getGasPrice()), 16) const lastGasPriceBN = new ethUtil.BN(ethUtil.stripHexPrefix(lastGasPrice), 16) // essentially lastGasPrice * 1.1 but diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d382b1ad0..c7e9cfcc7 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1144,8 +1144,8 @@ module.exports = class MetamaskController extends EventEmitter { * @param {string} txId - The ID of the transaction to speed up. * @param {Function} cb - The callback function called with a full state update. */ - async retryTransaction (txId, cb) { - await this.txController.retryTransaction(txId) + async retryTransaction (txId, gasPrice, cb) { + await this.txController.retryTransaction(txId, gasPrice) const state = await this.getState() return state } @@ -1158,9 +1158,13 @@ module.exports = class MetamaskController extends EventEmitter { * @returns {object} MetaMask state */ async createCancelTransaction (originalTxId, customGasPrice, cb) { - await this.txController.createCancelTransaction(originalTxId, customGasPrice) - const state = await this.getState() - return state + try { + await this.txController.createCancelTransaction(originalTxId, customGasPrice) + const state = await this.getState() + return state + } catch (error) { + throw error + } } async createSpeedUpTransaction (originalTxId, customGasPrice, cb) { diff --git a/test/integration/lib/confirm-sig-requests.js b/test/integration/lib/confirm-sig-requests.js index 9c2ad7cf4..041a1af34 100644 --- a/test/integration/lib/confirm-sig-requests.js +++ b/test/integration/lib/confirm-sig-requests.js @@ -21,8 +21,8 @@ async function runConfirmSigRequestsTest (assert, done) { const pendingRequestItem = $.find('.transaction-list-item .transaction-list-item__grid') - if (pendingRequestItem[0]) { - pendingRequestItem[0].click() + if (pendingRequestItem[2]) { + pendingRequestItem[2].click() } await timeout(1000) diff --git a/test/integration/lib/tx-list-items.js b/test/integration/lib/tx-list-items.js index ed4f82074..ff196fac8 100644 --- a/test/integration/lib/tx-list-items.js +++ b/test/integration/lib/tx-list-items.js @@ -30,35 +30,25 @@ async function runTxListItemsTest (assert, done) { metamaskLogo[0].click() const txListItems = await queryAsync($, '.transaction-list-item') - assert.equal(txListItems.length, 8, 'all tx list items are rendered') + assert.equal(txListItems.length, 7, 'all tx list items are rendered') - const retryTxGrid = await findAsync($(txListItems[2]), '.transaction-list-item__grid') - retryTxGrid[0].click() - const retryTxDetails = await findAsync($, '.transaction-list-item-details') - const headerButtons = await findAsync($(retryTxDetails[0]), '.transaction-list-item-details__header-button') - assert.equal(headerButtons[0].textContent, 'speed up') - - const approvedTx = txListItems[2] + const approvedTx = txListItems[0] const approvedTxRenderedStatus = await findAsync($(approvedTx), '.transaction-list-item__status') assert.equal(approvedTxRenderedStatus[0].textContent, 'pending', 'approvedTx has correct label') - const unapprovedMsg = txListItems[0] + const unapprovedMsg = txListItems[1] const unapprovedMsgDescription = await findAsync($(unapprovedMsg), '.transaction-list-item__action') assert.equal(unapprovedMsgDescription[0].textContent, 'Signature Request', 'unapprovedMsg has correct description') - const failedTx = txListItems[4] - const failedTxRenderedStatus = await findAsync($(failedTx), '.transaction-list-item__status') - assert.equal(failedTxRenderedStatus[0].textContent, 'Failed', 'failedTx has correct label') - - const shapeShiftTx = txListItems[5] + const shapeShiftTx = txListItems[4] const shapeShiftTxStatus = await findAsync($(shapeShiftTx), '.flex-column div:eq(1)') assert.equal(shapeShiftTxStatus[0].textContent, 'No deposits received', 'shapeShiftTx has correct status') + const rejectedTx = txListItems[5] + const rejectedTxRenderedStatus = await findAsync($(rejectedTx), '.transaction-list-item__status') + assert.equal(rejectedTxRenderedStatus[0].textContent, 'Rejected', 'rejectedTx has correct label') + const confirmedTokenTx = txListItems[6] const confirmedTokenTxAddress = await findAsync($(confirmedTokenTx), '.transaction-list-item__status') assert.equal(confirmedTokenTxAddress[0].textContent, 'Confirmed', 'confirmedTokenTx has correct address') - - const rejectedTx = txListItems[7] - const rejectedTxRenderedStatus = await findAsync($(rejectedTx), '.transaction-list-item__status') - assert.equal(rejectedTxRenderedStatus[0].textContent, 'Rejected', 'rejectedTx has correct label') } diff --git a/ui/app/actions.js b/ui/app/actions.js index cd24aed0a..fa175177e 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -1793,13 +1793,13 @@ function markAccountsFound () { return callBackgroundThenUpdate(background.markAccountsFound) } -function retryTransaction (txId) { +function retryTransaction (txId, gasPrice) { log.debug(`background.retryTransaction`) let newTxId - return (dispatch) => { + return dispatch => { return new Promise((resolve, reject) => { - background.retryTransaction(txId, (err, newState) => { + background.retryTransaction(txId, gasPrice, (err, newState) => { if (err) { dispatch(actions.displayWarning(err.message)) reject(err) diff --git a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js b/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js index eede8b1ee..10931a001 100644 --- a/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js +++ b/ui/app/components/modals/cancel-transaction/cancel-transaction.container.js @@ -28,31 +28,29 @@ const mapStateToProps = (state, ownProps) => { transactionId, transactionStatus, originalGasPrice, + defaultNewGasPrice, newGasFee, } } const mapDispatchToProps = dispatch => { return { - createCancelTransaction: txId => dispatch(createCancelTransaction(txId)), + createCancelTransaction: (txId, customGasPrice) => { + return dispatch(createCancelTransaction(txId, customGasPrice)) + }, showTransactionConfirmedModal: () => dispatch(showModal({ name: 'TRANSACTION_CONFIRMED' })), } } const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { transactionId, ...restStateProps } = stateProps - const { - createCancelTransaction: dispatchCreateCancelTransaction, - ...restDispatchProps - } = dispatchProps + const { transactionId, defaultNewGasPrice, ...restStateProps } = stateProps + const { createCancelTransaction, ...restDispatchProps } = dispatchProps return { ...restStateProps, ...restDispatchProps, ...ownProps, - createCancelTransaction: newGasPrice => { - return dispatchCreateCancelTransaction(transactionId, newGasPrice) - }, + createCancelTransaction: () => createCancelTransaction(transactionId, defaultNewGasPrice), } } diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js index e3abde233..6bc415781 100644 --- a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -426,7 +426,7 @@ export default class ConfirmTransactionBase extends Component { toName={toName} toAddress={toAddress} showEdit={onEdit && !isTxReprice} - action={action || name || this.context.t('unknownFunction')} + action={action || name || this.context.t('contractInteraction')} title={title} titleComponent={this.renderTitleComponent()} subtitle={subtitle} diff --git a/ui/app/components/sender-to-recipient/index.scss b/ui/app/components/sender-to-recipient/index.scss index 0ab0413be..b21e4e1bb 100644 --- a/ui/app/components/sender-to-recipient/index.scss +++ b/ui/app/components/sender-to-recipient/index.scss @@ -1,12 +1,13 @@ .sender-to-recipient { + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + position: relative; + flex: 0 0 auto; + &--default { - width: 100%; - display: flex; - flex-direction: row; - justify-content: center; border-bottom: 1px solid $geyser; - position: relative; - flex: 0 0 auto; height: 42px; .sender-to-recipient { @@ -74,13 +75,6 @@ } &--cards { - width: 100%; - display: flex; - flex-direction: row; - justify-content: center; - position: relative; - flex: 0 0 auto; - .sender-to-recipient { &__party { display: flex; @@ -117,4 +111,39 @@ } } } + + &--flat { + .sender-to-recipient { + &__party { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex: 1; + padding: 6px; + cursor: pointer; + min-width: 0; + color: $dusty-gray; + } + + &__tooltip-wrapper { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: .6875rem; + } + + &__arrow-container { + display: flex; + justify-content: center; + align-items: center; + } + } + } } diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js index e71bd7406..89a1a9c08 100644 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js @@ -4,12 +4,13 @@ import classnames from 'classnames' import Identicon from '../identicon' import Tooltip from '../tooltip-v2' import copyToClipboard from 'copy-to-clipboard' -import { DEFAULT_VARIANT, CARDS_VARIANT } from './sender-to-recipient.constants' +import { DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT } from './sender-to-recipient.constants' import { checksumAddress } from '../../util' const variantHash = { [DEFAULT_VARIANT]: 'sender-to-recipient--default', [CARDS_VARIANT]: 'sender-to-recipient--cards', + [FLAT_VARIANT]: 'sender-to-recipient--flat', } export default class SenderToRecipient extends PureComponent { @@ -19,7 +20,7 @@ export default class SenderToRecipient extends PureComponent { recipientName: PropTypes.string, recipientAddress: PropTypes.string, t: PropTypes.func, - variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT]), + variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT]), addressOnly: PropTypes.bool, assetImage: PropTypes.string, } @@ -128,15 +129,8 @@ export default class SenderToRecipient extends PureComponent { } renderArrow () { - return this.props.variant === CARDS_VARIANT + return this.props.variant === DEFAULT_VARIANT ? ( -
- -
- ) : (
+ ) : ( +
+ +
) } @@ -154,7 +155,7 @@ export default class SenderToRecipient extends PureComponent { const checksummedSenderAddress = checksumAddress(senderAddress) return ( -
+
{ diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js b/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js index 166228932..f53a5115d 100644 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.constants.js @@ -1,3 +1,4 @@ // Component design variants export const DEFAULT_VARIANT = 'DEFAULT_VARIANT' export const CARDS_VARIANT = 'CARDS_VARIANT' +export const FLAT_VARIANT = 'FLAT_VARIANT' diff --git a/ui/app/components/transaction-activity-log/index.scss b/ui/app/components/transaction-activity-log/index.scss index 27f3006b3..00c17e6aa 100644 --- a/ui/app/components/transaction-activity-log/index.scss +++ b/ui/app/components/transaction-activity-log/index.scss @@ -1,7 +1,8 @@ .transaction-activity-log { - &__card { - background: $white; - height: 100%; + &__title { + border-bottom: 1px solid #d8d8d8; + padding-bottom: 4px; + text-transform: capitalize; } &__activities-container { @@ -21,8 +22,8 @@ left: 0; top: 0; height: 100%; - width: 6px; - border-right: 1px solid $scorpion; + width: 7px; + border-right: 1px solid #909090; } &:first-child::after { @@ -40,22 +41,25 @@ } &__activity-icon { - width: 13px; - height: 13px; + width: 15px; + height: 15px; margin-right: 6px; border-radius: 50%; - background: $scorpion; + background: #909090; flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + z-index: 1; } &__activity-text { - color: $scorpion; + color: $dusty-gray; font-size: .75rem; + cursor: pointer; - @media screen and (min-width: $break-large) { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + &:hover { + color: $black; } } @@ -64,6 +68,16 @@ font-weight: 500; } + &__entry-container { + min-width: 0; + } + + &__action-link { + font-size: .75rem; + cursor: pointer; + color: $curious-blue; + } + b { font-weight: 500; } diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js index 8687dbbc7..a2946e53d 100644 --- a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.component.test.js @@ -2,34 +2,100 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import TransactionActivityLog from '../transaction-activity-log.component' -import Card from '../../card' describe('TransactionActivityLog Component', () => { it('should render properly', () => { - const transaction = { - history: [], - id: 1, - status: 'confirmed', - txParams: { - from: '0x1', - gas: '0x5208', - gasPrice: '0x3b9aca00', - nonce: '0xa4', - to: '0x2', + const activities = [ + { + eventKey: 'transactionCreated', + hash: '0xe46c7f9b39af2fbf1c53e66f72f80343ab54c2c6dba902d51fb98ada08fe1a63', + id: 2005383477493174, + timestamp: 1543957986150, value: '0x2386f26fc10000', + }, { + eventKey: 'transactionSubmitted', + hash: '0xe46c7f9b39af2fbf1c53e66f72f80343ab54c2c6dba902d51fb98ada08fe1a63', + id: 2005383477493174, + timestamp: 1543957987853, + value: '0x1319718a5000', + }, { + eventKey: 'transactionResubmitted', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 2005383477493175, + timestamp: 1543957991563, + value: '0x1502634b5800', + }, { + eventKey: 'transactionConfirmed', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 2005383477493175, + timestamp: 1543958029960, + value: '0x1502634b5800', }, - } + ] const wrapper = shallow( {}} + onRetry={() => {}} + primaryTransactionStatus="confirmed" />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } ) assert.ok(wrapper.hasClass('transaction-activity-log')) assert.ok(wrapper.hasClass('test-class')) - assert.equal(wrapper.find(Card).length, 1) + }) + + it('should render inline retry and cancel buttons', () => { + const activities = [ + { + eventKey: 'transactionCreated', + hash: '0xa', + id: 1, + timestamp: 1, + value: '0x1', + }, { + eventKey: 'transactionSubmitted', + hash: '0xa', + id: 1, + timestamp: 2, + value: '0x1', + }, { + eventKey: 'transactionResubmitted', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 2, + timestamp: 3, + value: '0x1', + }, { + eventKey: 'transactionCancelAttempted', + hash: '0x7d09d337fc6f5d6fe2dbf3a6988d69532deb0a82b665f9180b5a20db377eea87', + id: 3, + timestamp: 4, + value: '0x1', + }, + ] + + const wrapper = shallow( + {}} + onRetry={() => {}} + primaryTransactionStatus="pending" + />, + { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } + ) + + assert.ok(wrapper.hasClass('transaction-activity-log')) + assert.ok(wrapper.hasClass('test-class')) + assert.equal(wrapper.find('.transaction-activity-log__action-link').length, 2) }) }) diff --git a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js index 586500408..d014b8886 100644 --- a/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js +++ b/ui/app/components/transaction-activity-log/tests/transaction-activity-log.util.test.js @@ -1,5 +1,130 @@ import assert from 'assert' -import { getActivities } from '../transaction-activity-log.util' +import { combineTransactionHistories, getActivities } from '../transaction-activity-log.util' + +describe('combineTransactionHistories', () => { + it('should return no activites for an empty list of transactions', () => { + assert.deepEqual(combineTransactionHistories([]), []) + }) + + it('should return activities for an array of transactions', () => { + const transactions = [ + { + estimatedGas: '0x5208', + hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3', + history: [ + { + 'id': 6400627574331058, + 'time': 1543958845581, + 'status': 'unapproved', + 'metamaskNetworkId': '3', + 'loadingDefaults': true, + 'txParams': { + 'from': '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + 'to': '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + 'value': '0x2386f26fc10000', + 'gas': '0x5208', + 'gasPrice': '0x3b9aca00', + }, + 'type': 'standard', + }, + [{ 'op': 'replace', 'path': '/status', 'value': 'approved', 'note': 'txStateManager: setting status to approved', 'timestamp': 1543958847813 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'submitted', 'note': 'txStateManager: setting status to submitted', 'timestamp': 1543958848147 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'dropped', 'note': 'txStateManager: setting status to dropped', 'timestamp': 1543958897181 }, { 'op': 'add', 'path': '/replacedBy', 'value': '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33' }], + ], + id: 6400627574331058, + loadingDefaults: false, + metamaskNetworkId: '3', + status: 'dropped', + submittedTime: 1543958848135, + time: 1543958845581, + txParams: { + from: '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + gas: '0x5208', + gasPrice: '0x3b9aca00', + nonce: '0x32', + to: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + value: '0x2386f26fc10000', + }, + type: 'standard', + }, { + hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33', + history: [ + { + 'id': 6400627574331060, + 'time': 1543958857697, + 'status': 'unapproved', + 'metamaskNetworkId': '3', + 'loadingDefaults': false, + 'txParams': { + 'from': '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + 'to': '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + 'value': '0x2386f26fc10000', + 'gas': '0x5208', + 'gasPrice': '0x3b9aca00', + 'nonce': '0x32', + }, + 'lastGasPrice': '0x4190ab00', + 'type': 'retry', + }, + [{ 'op': 'replace', 'path': '/txParams/gasPrice', 'value': '0x481f2280', 'note': 'confTx: user approved transaction', 'timestamp': 1543958859470 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'approved', 'note': 'txStateManager: setting status to approved', 'timestamp': 1543958859485 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'signed', 'note': 'transactions#publishTransaction', 'timestamp': 1543958859889 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'submitted', 'note': 'txStateManager: setting status to submitted', 'timestamp': 1543958860061 }], [{ 'op': 'add', 'path': '/firstRetryBlockNumber', 'value': '0x45a0fd', 'note': 'transactions/pending-tx-tracker#event: tx:block-update', 'timestamp': 1543958896466 }], + [{ 'op': 'replace', 'path': '/status', 'value': 'confirmed', 'timestamp': 1543958897165 }], + ], + id: 6400627574331060, + lastGasPrice: '0x4190ab00', + loadingDefaults: false, + metamaskNetworkId: '3', + status: 'confirmed', + submittedTime: 1543958860054, + time: 1543958857697, + txParams: { + from: '0x50a9d56c2b8ba9a5c7f2c08c3d26e0499f23a706', + gas: '0x5208', + gasPrice: '0x481f2280', + nonce: '0x32', + to: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + value: '0x2386f26fc10000', + }, + txReceipt: { + status: '0x1', + }, + type: 'retry', + }, + ] + + const expected = [ + { + id: 6400627574331058, + hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3', + eventKey: 'transactionCreated', + timestamp: 1543958845581, + value: '0x2386f26fc10000', + }, { + id: 6400627574331058, + hash: '0xa14f13d36b3901e352ce3a7acb9b47b001e5a3370f06232a0953c6fc6fad91b3', + eventKey: 'transactionSubmitted', + timestamp: 1543958848147, + value: '0x1319718a5000', + }, { + id: 6400627574331060, + hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33', + eventKey: 'transactionResubmitted', + timestamp: 1543958860061, + value: '0x171c3a061400', + }, { + id: 6400627574331060, + hash: '0xecbe181ee67c4291d04a7cb9ffbf1d5d831e4fbaa89994fd06bab5dd4cc79b33', + eventKey: 'transactionConfirmed', + timestamp: 1543958897165, + value: '0x171c3a061400', + }, + ] + + assert.deepEqual(combineTransactionHistories(transactions), expected) + }) +}) describe('getActivities', () => { it('should return no activities for an empty history', () => { @@ -178,6 +303,7 @@ describe('getActivities', () => { to: '0x2', value: '0x2386f26fc10000', }, + hash: '0xabc', } const expectedResult = [ @@ -185,24 +311,25 @@ describe('getActivities', () => { 'eventKey': 'transactionCreated', 'timestamp': 1535507561452, 'value': '0x2386f26fc10000', - }, - { - 'eventKey': 'transactionUpdatedGas', - 'timestamp': 1535664571504, - 'value': '0x77359400', + 'id': 1, + 'hash': '0xabc', }, { 'eventKey': 'transactionSubmitted', 'timestamp': 1535507564665, - 'value': undefined, + 'value': '0x2632e314a000', + 'id': 1, + 'hash': '0xabc', }, { 'eventKey': 'transactionConfirmed', 'timestamp': 1535507615993, - 'value': undefined, + 'value': '0x2632e314a000', + 'id': 1, + 'hash': '0xabc', }, ] - assert.deepEqual(getActivities(transaction), expectedResult) + assert.deepEqual(getActivities(transaction, true), expectedResult) }) }) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js new file mode 100644 index 000000000..86b12360a --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/index.js @@ -0,0 +1 @@ +export { default } from './transaction-activity-log-icon.component' diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js new file mode 100644 index 000000000..871716002 --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log-icon/transaction-activity-log-icon.component.js @@ -0,0 +1,55 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import { + TRANSACTION_CREATED_EVENT, + TRANSACTION_SUBMITTED_EVENT, + TRANSACTION_RESUBMITTED_EVENT, + TRANSACTION_CONFIRMED_EVENT, + TRANSACTION_DROPPED_EVENT, + TRANSACTION_ERRORED_EVENT, + TRANSACTION_CANCEL_ATTEMPTED_EVENT, + TRANSACTION_CANCEL_SUCCESS_EVENT, +} from '../transaction-activity-log.constants' + +const imageHash = { + [TRANSACTION_CREATED_EVENT]: '/images/icons/new.svg', + [TRANSACTION_SUBMITTED_EVENT]: '/images/icons/submitted.svg', + [TRANSACTION_RESUBMITTED_EVENT]: '/images/icons/retry.svg', + [TRANSACTION_CONFIRMED_EVENT]: '/images/icons/confirm.svg', + [TRANSACTION_DROPPED_EVENT]: '/images/icons/cancelled.svg', + [TRANSACTION_ERRORED_EVENT]: '/images/icons/error.svg', + [TRANSACTION_CANCEL_ATTEMPTED_EVENT]: '/images/icons/cancelled.svg', + [TRANSACTION_CANCEL_SUCCESS_EVENT]: '/images/icons/cancelled.svg', +} + +export default class TransactionActivityLogIcon extends PureComponent { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + className: PropTypes.string, + eventKey: PropTypes.oneOf(Object.keys(imageHash)), + } + + render () { + const { className, eventKey } = this.props + const imagePath = imageHash[eventKey] + + return ( +
+ { + imagePath && ( + + ) + } +
+ ) + } +} diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js index 58d932a0f..d6f90860a 100644 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js @@ -1,10 +1,11 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import { getActivities } from './transaction-activity-log.util' -import Card from '../card' import { getEthConversionFromWeiHex, getValueFromWeiHex } from '../../helpers/conversions.util' import { formatDate } from '../../util' +import TransactionActivityLogIcon from './transaction-activity-log-icon' +import { CONFIRMED_STATUS } from './transaction-activity-log.constants' +import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' export default class TransactionActivityLog extends PureComponent { static contextTypes = { @@ -12,41 +13,64 @@ export default class TransactionActivityLog extends PureComponent { } static propTypes = { - transaction: PropTypes.object, + activities: PropTypes.array, className: PropTypes.string, conversionRate: PropTypes.number, + inlineRetryIndex: PropTypes.number, + inlineCancelIndex: PropTypes.number, nativeCurrency: PropTypes.string, + onCancel: PropTypes.func, + onRetry: PropTypes.func, + primaryTransaction: PropTypes.object, } - state = { - activities: [], + handleActivityClick = hash => { + const { primaryTransaction } = this.props + const { metamaskNetworkId } = primaryTransaction + + const prefix = prefixForNetwork(metamaskNetworkId) + const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` + + global.platform.openWindow({ url: etherscanUrl }) } - componentDidMount () { - this.setActivites() + renderInlineRetry (index, activity) { + const { t } = this.context + const { inlineRetryIndex, primaryTransaction = {}, onRetry } = this.props + const { status } = primaryTransaction + const { id } = activity + + return status !== CONFIRMED_STATUS && index === inlineRetryIndex + ? ( +
onRetry(id)} + > + { t('speedUpTransaction') } +
+ ) : null } - componentDidUpdate (prevProps) { - const { - transaction: { history: prevHistory = [], txReceipt: { status: prevStatus } = {} } = {}, - } = prevProps - const { - transaction: { history = [], txReceipt: { status } = {} } = {}, - } = this.props + renderInlineCancel (index, activity) { + const { t } = this.context + const { inlineCancelIndex, primaryTransaction = {}, onCancel } = this.props + const { status } = primaryTransaction + const { id } = activity - if (prevHistory.length !== history.length || prevStatus !== status) { - this.setActivites() - } - } - - setActivites () { - const activities = getActivities(this.props.transaction) - this.setState({ activities }) + return status !== CONFIRMED_STATUS && index === inlineCancelIndex + ? ( +
onCancel(id)} + > + { t('speedUpCancellation') } +
+ ) : null } renderActivity (activity, index) { const { conversionRate, nativeCurrency } = this.props - const { eventKey, value, timestamp } = activity + const { eventKey, value, timestamp, hash } = activity const ethValue = index === 0 ? `${getValueFromWeiHex({ value, @@ -55,8 +79,13 @@ export default class TransactionActivityLog extends PureComponent { conversionRate, numberOfDecimals: 6, })} ${nativeCurrency}` - : getEthConversionFromWeiHex({ value, fromCurrency: nativeCurrency, conversionRate }) - const formattedTimestamp = formatDate(timestamp) + : getEthConversionFromWeiHex({ + value, + fromCurrency: nativeCurrency, + conversionRate, + numberOfDecimals: 3, + }) + const formattedTimestamp = formatDate(timestamp, '14:30 on 3/16/2014') const activityText = this.context.t(eventKey, [ethValue, formattedTimestamp]) return ( @@ -64,12 +93,20 @@ export default class TransactionActivityLog extends PureComponent { key={index} className="transaction-activity-log__activity" > -
-
- { activityText } + +
+
this.handleActivityClick(hash)} + > + { activityText } +
+ { this.renderInlineRetry(index, activity) } + { this.renderInlineCancel(index, activity) }
) @@ -77,19 +114,16 @@ export default class TransactionActivityLog extends PureComponent { render () { const { t } = this.context - const { className } = this.props - const { activities } = this.state + const { className, activities } = this.props return (
- -
- { activities.map((activity, index) => this.renderActivity(activity, index)) } -
-
+
+ { t('activityLog') } +
+
+ { activities.map((activity, index) => this.renderActivity(activity, index)) } +
) } diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.constants.js b/ui/app/components/transaction-activity-log/transaction-activity-log.constants.js new file mode 100644 index 000000000..72e63d85c --- /dev/null +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.constants.js @@ -0,0 +1,13 @@ +export const TRANSACTION_CREATED_EVENT = 'transactionCreated' +export const TRANSACTION_SUBMITTED_EVENT = 'transactionSubmitted' +export const TRANSACTION_RESUBMITTED_EVENT = 'transactionResubmitted' +export const TRANSACTION_CONFIRMED_EVENT = 'transactionConfirmed' +export const TRANSACTION_DROPPED_EVENT = 'transactionDropped' +export const TRANSACTION_UPDATED_EVENT = 'transactionUpdated' +export const TRANSACTION_ERRORED_EVENT = 'transactionErrored' +export const TRANSACTION_CANCEL_ATTEMPTED_EVENT = 'transactionCancelAttempted' +export const TRANSACTION_CANCEL_SUCCESS_EVENT = 'transactionCancelSuccess' + +export const SUBMITTED_STATUS = 'submitted' +export const CONFIRMED_STATUS = 'confirmed' +export const DROPPED_STATUS = 'dropped' diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js index 622f77df1..e43229708 100644 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.container.js +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.container.js @@ -1,6 +1,14 @@ import { connect } from 'react-redux' +import R from 'ramda' import TransactionActivityLog from './transaction-activity-log.component' import { conversionRateSelector, getNativeCurrency } from '../../selectors' +import { combineTransactionHistories } from './transaction-activity-log.util' +import { + TRANSACTION_RESUBMITTED_EVENT, + TRANSACTION_CANCEL_ATTEMPTED_EVENT, +} from './transaction-activity-log.constants' + +const matchesEventKey = matchEventKey => ({ eventKey }) => eventKey === matchEventKey const mapStateToProps = state => { return { @@ -9,4 +17,28 @@ const mapStateToProps = state => { } } -export default connect(mapStateToProps)(TransactionActivityLog) +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { + transactionGroup: { + transactions = [], + primaryTransaction, + } = {}, + ...restOwnProps + } = ownProps + + const activities = combineTransactionHistories(transactions) + const inlineRetryIndex = R.findLastIndex(matchesEventKey(TRANSACTION_RESUBMITTED_EVENT))(activities) + const inlineCancelIndex = R.findLastIndex(matchesEventKey(TRANSACTION_CANCEL_ATTEMPTED_EVENT))(activities) + + return { + ...stateProps, + ...dispatchProps, + ...restOwnProps, + activities, + inlineRetryIndex, + inlineCancelIndex, + primaryTransaction, + } +} + +export default connect(mapStateToProps, null, mergeProps)(TransactionActivityLog) diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js index 16597ae1a..6206a4678 100644 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.util.js +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.util.js @@ -1,28 +1,39 @@ +import { getHexGasTotal } from '../../helpers/confirm-transaction/util' + // path constants const STATUS_PATH = '/status' const GAS_PRICE_PATH = '/txParams/gasPrice' - -// status constants -const UNAPPROVED_STATUS = 'unapproved' -const SUBMITTED_STATUS = 'submitted' -const CONFIRMED_STATUS = 'confirmed' -const DROPPED_STATUS = 'dropped' +const GAS_LIMIT_PATH = '/txParams/gas' // op constants const REPLACE_OP = 'replace' -// event constants -const TRANSACTION_CREATED_EVENT = 'transactionCreated' -const TRANSACTION_UPDATED_GAS_EVENT = 'transactionUpdatedGas' -const TRANSACTION_SUBMITTED_EVENT = 'transactionSubmitted' -const TRANSACTION_CONFIRMED_EVENT = 'transactionConfirmed' -const TRANSACTION_DROPPED_EVENT = 'transactionDropped' -const TRANSACTION_UPDATED_EVENT = 'transactionUpdated' -const TRANSACTION_ERRORED_EVENT = 'transactionErrored' +import { + // event constants + TRANSACTION_CREATED_EVENT, + TRANSACTION_SUBMITTED_EVENT, + TRANSACTION_RESUBMITTED_EVENT, + TRANSACTION_CONFIRMED_EVENT, + TRANSACTION_DROPPED_EVENT, + TRANSACTION_UPDATED_EVENT, + TRANSACTION_ERRORED_EVENT, + TRANSACTION_CANCEL_ATTEMPTED_EVENT, + TRANSACTION_CANCEL_SUCCESS_EVENT, + // status constants + SUBMITTED_STATUS, + CONFIRMED_STATUS, + DROPPED_STATUS, +} from './transaction-activity-log.constants' + +import { + TRANSACTION_TYPE_CANCEL, + TRANSACTION_TYPE_RETRY, +} from '../../../../app/scripts/controllers/transactions/enums' const eventPathsHash = { [STATUS_PATH]: true, [GAS_PRICE_PATH]: true, + [GAS_LIMIT_PATH]: true, } const statusHash = { @@ -31,22 +42,39 @@ const statusHash = { [DROPPED_STATUS]: TRANSACTION_DROPPED_EVENT, } -function eventCreator (eventKey, timestamp, value) { - return { - eventKey, - timestamp, - value, - } -} +/** + * @name getActivities + * @param {Object} transaction - txMeta object + * @param {boolean} isFirstTransaction - True if the transaction is the first created transaction + * in the list of transactions with the same nonce. If so, we use this transaction to create the + * transactionCreated activity. + * @returns {Array} + */ +export function getActivities (transaction, isFirstTransaction = false) { + const { id, hash, history = [], txReceipt: { status } = {}, type } = transaction -export function getActivities (transaction) { - const { history = [], txReceipt: { status } = {} } = transaction + let cachedGasLimit = '0x0' + let cachedGasPrice = '0x0' - const historyActivities = history.reduce((acc, base) => { + const historyActivities = history.reduce((acc, base, index) => { // First history item should be transaction creation - if (!Array.isArray(base) && base.status === UNAPPROVED_STATUS && base.txParams) { - const { time, txParams: { value } = {} } = base - return acc.concat(eventCreator(TRANSACTION_CREATED_EVENT, time, value)) + if (index === 0 && !Array.isArray(base) && base.txParams) { + const { time: timestamp, txParams: { value, gas = '0x0', gasPrice = '0x0' } = {} } = base + // The cached gas limit and gas price are used to display the gas fee in the activity log. We + // need to cache these values because the status update history events don't provide us with + // the latest gas limit and gas price. + cachedGasLimit = gas + cachedGasPrice = gasPrice + + if (isFirstTransaction) { + return acc.concat({ + id, + hash, + eventKey: TRANSACTION_CREATED_EVENT, + timestamp, + value, + }) + } // An entry in the history may be an array of more sub-entries. } else if (Array.isArray(base)) { const events = [] @@ -60,20 +88,69 @@ export function getActivities (transaction) { if (path in eventPathsHash && op === REPLACE_OP) { switch (path) { case STATUS_PATH: { + const gasFee = getHexGasTotal({ gasLimit: cachedGasLimit, gasPrice: cachedGasPrice }) + if (value in statusHash) { - events.push(eventCreator(statusHash[value], timestamp)) + let eventKey = statusHash[value] + + // If the status is 'submitted', we need to determine whether the event is a + // transaction retry or a cancellation attempt. + if (value === SUBMITTED_STATUS) { + if (type === TRANSACTION_TYPE_RETRY) { + eventKey = TRANSACTION_RESUBMITTED_EVENT + } else if (type === TRANSACTION_TYPE_CANCEL) { + eventKey = TRANSACTION_CANCEL_ATTEMPTED_EVENT + } + } else if (value === CONFIRMED_STATUS) { + if (type === TRANSACTION_TYPE_CANCEL) { + eventKey = TRANSACTION_CANCEL_SUCCESS_EVENT + } + } + + events.push({ + id, + hash, + eventKey, + timestamp, + value: gasFee, + }) } break } - case GAS_PRICE_PATH: { - events.push(eventCreator(TRANSACTION_UPDATED_GAS_EVENT, timestamp, value)) + // If the gas price or gas limit has been changed, we update the gasFee of the + // previously submitted event. These events happen when the gas limit and gas price is + // changed at the confirm screen. + case GAS_PRICE_PATH: + case GAS_LIMIT_PATH: { + const lastEvent = events[events.length - 1] || {} + const { lastEventKey } = lastEvent + + if (path === GAS_LIMIT_PATH) { + cachedGasLimit = value + } else if (path === GAS_PRICE_PATH) { + cachedGasPrice = value + } + + if (lastEventKey === TRANSACTION_SUBMITTED_EVENT || + lastEventKey === TRANSACTION_RESUBMITTED_EVENT) { + lastEvent.value = getHexGasTotal({ + gasLimit: cachedGasLimit, + gasPrice: cachedGasPrice, + }) + } + break } default: { - events.push(eventCreator(TRANSACTION_UPDATED_EVENT, timestamp)) + events.push({ + id, + hash, + eventKey: TRANSACTION_UPDATED_EVENT, + timestamp, + }) } } } @@ -88,6 +165,60 @@ export function getActivities (transaction) { // If txReceipt.status is '0x0', that means that an on-chain error occured for the transaction, // so we add an error entry to the Activity Log. return status === '0x0' - ? historyActivities.concat(eventCreator(TRANSACTION_ERRORED_EVENT)) + ? historyActivities.concat({ id, hash, eventKey: TRANSACTION_ERRORED_EVENT }) : historyActivities } + +/** + * @description Removes "Transaction dropped" activities from a list of sorted activities if one of + * the transactions has been confirmed. Typically, if multiple transactions have the same nonce, + * once one transaction is confirmed, the rest are dropped. In this case, we don't want to show + * multiple "Transaction dropped" activities, and instead want to show a single "Transaction + * confirmed". + * @param {Array} activities - List of sorted activities generated from the getActivities function. + * @returns {Array} + */ +function filterSortedActivities (activities) { + const filteredActivities = [] + const hasConfirmedActivity = Boolean(activities.find(({ eventKey }) => ( + eventKey === TRANSACTION_CONFIRMED_EVENT || eventKey === TRANSACTION_CANCEL_SUCCESS_EVENT + ))) + let addedDroppedActivity = false + + activities.forEach(activity => { + if (activity.eventKey === TRANSACTION_DROPPED_EVENT) { + if (!hasConfirmedActivity && !addedDroppedActivity) { + filteredActivities.push(activity) + addedDroppedActivity = true + } + } else { + filteredActivities.push(activity) + } + }) + + return filteredActivities +} + +/** + * Combines the histories of an array of transactions into a single array. + * @param {Array} transactions - Array of txMeta transaction objects. + * @returns {Array} + */ +export function combineTransactionHistories (transactions = []) { + if (!transactions.length) { + return [] + } + + const activities = [] + + transactions.forEach((transaction, index) => { + // The first transaction should be the transaction with the earliest submittedTime. We show the + // 'created' and 'submitted' activities here. All subsequent transactions will use 'resubmitted' + // instead. + const transactionActivities = getActivities(transaction, index === 0) + activities.push(...transactionActivities) + }) + + const sortedActivities = activities.sort((a, b) => a.timestamp - b.timestamp) + return filterSortedActivities(sortedActivities) +} diff --git a/ui/app/components/transaction-breakdown/index.scss b/ui/app/components/transaction-breakdown/index.scss index 1bb108943..b56cbdd7f 100644 --- a/ui/app/components/transaction-breakdown/index.scss +++ b/ui/app/components/transaction-breakdown/index.scss @@ -1,9 +1,10 @@ @import './transaction-breakdown-row/index'; .transaction-breakdown { - &__card { - background: $white; - height: 100%; + &__title { + border-bottom: 1px solid #d8d8d8; + padding-bottom: 4px; + text-transform: capitalize; } &__row-title { diff --git a/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js b/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js index d18cd420c..4512b84f0 100644 --- a/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js +++ b/ui/app/components/transaction-breakdown/tests/transaction-breakdown.component.test.js @@ -2,8 +2,6 @@ import React from 'react' import assert from 'assert' import { shallow } from 'enzyme' import TransactionBreakdown from '../transaction-breakdown.component' -import TransactionBreakdownRow from '../transaction-breakdown-row' -import Card from '../../card' describe('TransactionBreakdown Component', () => { it('should render properly', () => { @@ -31,7 +29,5 @@ describe('TransactionBreakdown Component', () => { assert.ok(wrapper.hasClass('transaction-breakdown')) assert.ok(wrapper.hasClass('test-class')) - assert.equal(wrapper.find(Card).length, 1) - assert.equal(wrapper.find(Card).find(TransactionBreakdownRow).length, 4) }) }) diff --git a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js index 3a7647873..141e16e17 100644 --- a/ui/app/components/transaction-breakdown/transaction-breakdown.component.js +++ b/ui/app/components/transaction-breakdown/transaction-breakdown.component.js @@ -2,7 +2,6 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import TransactionBreakdownRow from './transaction-breakdown-row' -import Card from '../card' import CurrencyDisplay from '../currency-display' import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' import HexToDecimal from '../hex-to-decimal' @@ -37,63 +36,61 @@ export default class TransactionBreakdown extends PureComponent { return (
- + { t('transaction') } +
+ + + + - + + + { + typeof gasUsed === 'string' && ( + + + + ) + } + + + + +
+ - - - - - { - typeof gasUsed === 'string' && ( - - - - ) - } - - - - -
- - -
-
- +
+
) } diff --git a/ui/app/components/transaction-list-item-details/index.scss b/ui/app/components/transaction-list-item-details/index.scss index 54cf834cc..2e3a06f84 100644 --- a/ui/app/components/transaction-list-item-details/index.scss +++ b/ui/app/components/transaction-list-item-details/index.scss @@ -1,11 +1,16 @@ .transaction-list-item-details { &__header { - margin-bottom: 8px; + margin: 8px 16px; display: flex; justify-content: space-between; align-items: center; } + &__body { + background: #fafbfc; + padding: 8px 16px; + } + &__header-buttons { display: flex; flex-direction: row; @@ -45,5 +50,9 @@ &__transaction-activity-log { flex: 2; min-width: 0; + + @media screen and (min-width: $break-large) { + padding-left: 12px; + } } } diff --git a/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js b/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js index f2bbe8789..62fc64db9 100644 --- a/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js +++ b/ui/app/components/transaction-list-item-details/tests/transaction-list-item-details.component.test.js @@ -23,9 +23,15 @@ describe('TransactionListItemDetails Component', () => { }, } + const transactionGroup = { + transactions: [transaction], + primaryTransaction: transaction, + initialTransaction: transaction, + } + const wrapper = shallow( , { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } ) @@ -52,9 +58,18 @@ describe('TransactionListItemDetails Component', () => { }, } + const transactionGroup = { + transactions: [transaction], + primaryTransaction: transaction, + initialTransaction: transaction, + nonce: '0xa4', + hasRetried: false, + hasCancelled: false, + } + const wrapper = shallow( , { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } } diff --git a/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js b/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js index a79213ace..cc2c45290 100644 --- a/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/app/components/transaction-list-item-details/transaction-list-item-details.component.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import SenderToRecipient from '../sender-to-recipient' -import { CARDS_VARIANT } from '../sender-to-recipient/sender-to-recipient.constants' +import { FLAT_VARIANT } from '../sender-to-recipient/sender-to-recipient.constants' import TransactionActivityLog from '../transaction-activity-log' import TransactionBreakdown from '../transaction-breakdown' import Button from '../button' @@ -18,42 +18,43 @@ export default class TransactionListItemDetails extends PureComponent { onRetry: PropTypes.func, showCancel: PropTypes.bool, showRetry: PropTypes.bool, - transaction: PropTypes.object, + transactionGroup: PropTypes.object, } handleEtherscanClick = () => { - const { hash, metamaskNetworkId } = this.props.transaction + const { transactionGroup: { primaryTransaction } } = this.props + const { hash, metamaskNetworkId } = primaryTransaction const prefix = prefixForNetwork(metamaskNetworkId) const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` global.platform.openWindow({ url: etherscanUrl }) - this.setState({ showTransactionDetails: true }) } handleCancel = event => { - const { onCancel } = this.props + const { transactionGroup: { initialTransaction: { id } = {} } = {}, onCancel } = this.props event.stopPropagation() - onCancel() + onCancel(id) } handleRetry = event => { - const { onRetry } = this.props + const { transactionGroup: { initialTransaction: { id } = {} } = {}, onRetry } = this.props event.stopPropagation() - onRetry() + onRetry(id) } render () { const { t } = this.context - const { transaction, showCancel, showRetry } = this.props + const { transactionGroup, showCancel, showRetry, onCancel, onRetry } = this.props + const { primaryTransaction: transaction } = transactionGroup const { txParams: { to, from } = {} } = transaction return (
-
Details
+
{ t('details') }
{ showRetry && ( @@ -88,23 +89,27 @@ export default class TransactionListItemDetails extends PureComponent {
-
- -
-
- - +
+
+ +
+
+ + +
) diff --git a/ui/app/components/transaction-list-item/index.scss b/ui/app/components/transaction-list-item/index.scss index 449974734..9e73a546c 100644 --- a/ui/app/components/transaction-list-item/index.scss +++ b/ui/app/components/transaction-list-item/index.scss @@ -117,12 +117,6 @@ } } - &__details-container { - padding: 8px 16px 16px; - background: #f3f4f7; - width: 100%; - } - &__expander { max-height: 0px; width: 100%; diff --git a/ui/app/components/transaction-list-item/transaction-list-item.component.js b/ui/app/components/transaction-list-item/transaction-list-item.component.js index 5334484db..ecd8b4cef 100644 --- a/ui/app/components/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/transaction-list-item/transaction-list-item.component.js @@ -18,6 +18,7 @@ export default class TransactionListItem extends PureComponent { history: PropTypes.object, methodData: PropTypes.object, nonceAndDate: PropTypes.string, + primaryTransaction: PropTypes.object, retryTransaction: PropTypes.func, setSelectedToken: PropTypes.func, showCancelModal: PropTypes.func, @@ -26,6 +27,7 @@ export default class TransactionListItem extends PureComponent { token: PropTypes.object, tokenData: PropTypes.object, transaction: PropTypes.object, + transactionGroup: PropTypes.object, value: PropTypes.string, fetchBasicGasAndTimeEstimates: PropTypes.func, fetchGasEstimates: PropTypes.func, @@ -51,36 +53,48 @@ export default class TransactionListItem extends PureComponent { this.setState({ showTransactionDetails: !showTransactionDetails }) } - handleCancel = () => { - const { transaction: { id, txParams: { gasPrice } } = {}, showCancelModal } = this.props - showCancelModal(id, gasPrice) + handleCancel = id => { + const { + primaryTransaction: { txParams: { gasPrice } } = {}, + transaction: { id: initialTransactionId }, + showCancelModal, + } = this.props + + const cancelId = id || initialTransactionId + showCancelModal(cancelId, gasPrice) } - handleRetry = () => { + /** + * @name handleRetry + * @description Resubmits a transaction. Retrying a transaction within a list of transactions with + * the same nonce requires keeping the original value while increasing the gas price of the latest + * transaction. + * @param {number} id - Transaction id + */ + handleRetry = id => { const { - transaction: { txParams: { to } = {} }, + primaryTransaction: { txParams: { gasPrice } } = {}, + transaction: { txParams: { to } = {}, id: initialTransactionId }, methodData: { name } = {}, setSelectedToken, + retryTransaction, + fetchBasicGasAndTimeEstimates, + fetchGasEstimates, } = this.props if (name === TOKEN_METHOD_TRANSFER) { setSelectedToken(to) } - return this.resubmit() - } + const retryId = id || initialTransactionId - resubmit () { - const { transaction, retryTransaction, fetchBasicGasAndTimeEstimates, fetchGasEstimates } = this.props - fetchBasicGasAndTimeEstimates().then(basicEstimates => { - fetchGasEstimates(basicEstimates.blockTime) - }).then(() => { - retryTransaction(transaction) - }) + return fetchBasicGasAndTimeEstimates() + .then(basicEstimates => fetchGasEstimates(basicEstimates.blockTime)) + .then(retryTransaction(retryId, gasPrice)) } renderPrimaryCurrency () { - const { token, transaction: { txParams: { data } = {} } = {}, value } = this.props + const { token, primaryTransaction: { txParams: { data } = {} } = {}, value } = this.props return token ? ( @@ -118,12 +132,14 @@ export default class TransactionListItem extends PureComponent { render () { const { assetImages, + transaction, methodData, nonceAndDate, + primaryTransaction, showCancel, showRetry, tokenData, - transaction, + transactionGroup, } = this.props const { txParams = {} } = transaction const { showTransactionDetails } = this.state @@ -156,11 +172,11 @@ export default class TransactionListItem extends PureComponent {
{ this.renderPrimaryCurrency() } @@ -173,7 +189,7 @@ export default class TransactionListItem extends PureComponent { showTransactionDetails && (
{ - const { transaction: { txParams: { value, nonce, data } = {}, time } = {} } = ownProps - - const tokenData = data && getTokenData(data) - const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) - - return { - value, - nonceAndDate, - tokenData, - } -} - const mapDispatchToProps = dispatch => { return { fetchBasicGasAndTimeEstimates: () => dispatch(fetchBasicGasAndTimeEstimates()), fetchGasEstimates: (blockTime) => dispatch(fetchGasEstimates(blockTime)), setSelectedToken: tokenAddress => dispatch(setSelectedToken(tokenAddress)), - retryTransaction: (transaction) => { - dispatch(setCustomGasPrice(transaction.txParams.gasPrice)) + retryTransaction: (transaction, gasPrice) => { + dispatch(setCustomGasPrice(gasPrice || transaction.txParams.gasPrice)) dispatch(setCustomGasLimit(transaction.txParams.gas)) dispatch(showSidebar({ transitionName: 'sidebar-left', @@ -47,8 +35,35 @@ const mapDispatchToProps = dispatch => { } } +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { transactionGroup: { primaryTransaction, initialTransaction } = {} } = ownProps + const { retryTransaction, ...restDispatchProps } = dispatchProps + const { txParams: { nonce, data } = {}, time } = initialTransaction + const { txParams: { value } = {} } = primaryTransaction + + const tokenData = data && getTokenData(data) + const nonceAndDate = nonce ? `#${hexToDecimal(nonce)} - ${formatDate(time)}` : formatDate(time) + + return { + ...stateProps, + ...restDispatchProps, + ...ownProps, + value, + nonceAndDate, + tokenData, + transaction: initialTransaction, + primaryTransaction, + retryTransaction: (transactionId, gasPrice) => { + const { transactionGroup: { transactions = [] } } = ownProps + const transaction = transactions.find(tx => tx.id === transactionId) || {} + const increasedGasPrice = increaseLastGasPrice(gasPrice) + retryTransaction(transaction, increasedGasPrice) + }, + } +} + export default compose( withRouter, - connect(mapStateToProps, mapDispatchToProps), + connect(null, mapDispatchToProps, mergeProps), withMethodData, )(TransactionListItem) diff --git a/ui/app/components/transaction-list/transaction-list.component.js b/ui/app/components/transaction-list/transaction-list.component.js index eef60186d..c1e3b3d1c 100644 --- a/ui/app/components/transaction-list/transaction-list.component.js +++ b/ui/app/components/transaction-list/transaction-list.component.js @@ -12,13 +12,11 @@ export default class TransactionList extends PureComponent { static defaultProps = { pendingTransactions: [], completedTransactions: [], - transactionToRetry: {}, } static propTypes = { pendingTransactions: PropTypes.array, completedTransactions: PropTypes.array, - transactionToRetry: PropTypes.object, selectedToken: PropTypes.object, updateNetworkNonce: PropTypes.func, assetImages: PropTypes.object, @@ -37,26 +35,34 @@ export default class TransactionList extends PureComponent { } } - shouldShowRetry = transaction => { - const { transactionToRetry } = this.props - const { id, submittedTime } = transaction - return id === transactionToRetry.id && Date.now() - submittedTime > 30000 + shouldShowRetry = (transactionGroup, isEarliestNonce) => { + const { transactions = [], hasRetried } = transactionGroup + const [earliestTransaction = {}] = transactions + const { submittedTime } = earliestTransaction + return Date.now() - submittedTime > 30000 && isEarliestNonce && !hasRetried + } + + shouldShowCancel (transactionGroup) { + const { hasCancelled } = transactionGroup + return !hasCancelled } renderTransactions () { const { t } = this.context const { pendingTransactions = [], completedTransactions = [] } = this.props + const pendingLength = pendingTransactions.length + return (
{ - pendingTransactions.length > 0 && ( + pendingLength > 0 && (
{ `${t('queue')} (${pendingTransactions.length})` }
{ - pendingTransactions.map((transaction, index) => ( - this.renderTransaction(transaction, index, true) + pendingTransactions.map((transactionGroup, index) => ( + this.renderTransaction(transactionGroup, index, true, index === pendingLength - 1) )) }
@@ -68,8 +74,8 @@ export default class TransactionList extends PureComponent {
{ completedTransactions.length > 0 - ? completedTransactions.map((transaction, index) => ( - this.renderTransaction(transaction, index) + ? completedTransactions.map((transactionGroup, index) => ( + this.renderTransaction(transactionGroup, index) )) : this.renderEmpty() } @@ -78,21 +84,22 @@ export default class TransactionList extends PureComponent { ) } - renderTransaction (transaction, index, showCancel) { + renderTransaction (transactionGroup, index, isPendingTx = false, isEarliestNonce = false) { const { selectedToken, assetImages } = this.props + const { transactions = [] } = transactionGroup - return transaction.key === TRANSACTION_TYPE_SHAPESHIFT + return transactions[0].key === TRANSACTION_TYPE_SHAPESHIFT ? ( ) : ( diff --git a/ui/app/components/transaction-list/transaction-list.container.js b/ui/app/components/transaction-list/transaction-list.container.js index 2e946c67d..e70ca15c5 100644 --- a/ui/app/components/transaction-list/transaction-list.container.js +++ b/ui/app/components/transaction-list/transaction-list.container.js @@ -3,24 +3,17 @@ import { withRouter } from 'react-router-dom' import { compose } from 'recompose' import TransactionList from './transaction-list.component' import { - pendingTransactionsSelector, - submittedPendingTransactionsSelector, - completedTransactionsSelector, + nonceSortedCompletedTransactionsSelector, + nonceSortedPendingTransactionsSelector, } from '../../selectors/transactions' import { getSelectedAddress, getAssetImages } from '../../selectors' import { selectedTokenSelector } from '../../selectors/tokens' -import { getLatestSubmittedTxWithNonce } from '../../helpers/transactions.util' import { updateNetworkNonce } from '../../actions' const mapStateToProps = state => { - const pendingTransactions = pendingTransactionsSelector(state) - const submittedPendingTransactions = submittedPendingTransactionsSelector(state) - const networkNonce = state.appState.networkNonce - return { - completedTransactions: completedTransactionsSelector(state), - pendingTransactions, - transactionToRetry: getLatestSubmittedTxWithNonce(submittedPendingTransactions, networkNonce), + completedTransactions: nonceSortedCompletedTransactionsSelector(state), + pendingTransactions: nonceSortedPendingTransactionsSelector(state), selectedToken: selectedTokenSelector(state), selectedAddress: getSelectedAddress(state), assetImages: getAssetImages(state), diff --git a/ui/app/components/transaction-status/index.scss b/ui/app/components/transaction-status/index.scss index 26a1f5d38..e7daafeef 100644 --- a/ui/app/components/transaction-status/index.scss +++ b/ui/app/components/transaction-status/index.scss @@ -1,6 +1,6 @@ .transaction-status { height: 26px; - width: 81px; + width: 84px; border-radius: 4px; background-color: #f0f0f0; color: #5e6064; @@ -12,22 +12,34 @@ @media screen and (max-width: $break-small) { height: 16px; - width: 70px; + width: 72px; font-size: .5rem; } &--confirmed { background-color: #eafad7; color: #609a1c; + + .transaction-status__transaction-count { + border: 1px solid #609a1c; + } } &--approved, &--submitted { background-color: #FFF2DB; color: #CA810A; + + .transaction-status__transaction-count { + border: 1px solid #CA810A; + } } &--failed { background: lighten($monzo, 56%); color: $monzo; + + .transaction-status__transaction-count { + border: 1px solid $monzo; + } } } diff --git a/ui/app/components/transaction-status/tests/transaction-status.component.test.js b/ui/app/components/transaction-status/tests/transaction-status.component.test.js index 9e3bffe4f..f4ddc9206 100644 --- a/ui/app/components/transaction-status/tests/transaction-status.component.test.js +++ b/ui/app/components/transaction-status/tests/transaction-status.component.test.js @@ -15,9 +15,8 @@ describe('TransactionStatus Component', () => { ) assert.ok(wrapper) - const tooltipProps = wrapper.find(Tooltip).props() - assert.equal(tooltipProps.children, 'APPROVED') - assert.equal(tooltipProps.title, 'test-title') + assert.equal(wrapper.text(), 'APPROVED') + assert.equal(wrapper.find(Tooltip).props().title, 'test-title') }) it('should render SUBMITTED properly', () => { @@ -29,7 +28,6 @@ describe('TransactionStatus Component', () => { ) assert.ok(wrapper) - const tooltipProps = wrapper.find(Tooltip).props() - assert.equal(tooltipProps.children, 'PENDING') + assert.equal(wrapper.text(), 'PENDING') }) }) diff --git a/ui/app/components/transaction-status/transaction-status.component.js b/ui/app/components/transaction-status/transaction-status.component.js index 0d47d7868..28544d2cd 100644 --- a/ui/app/components/transaction-status/transaction-status.component.js +++ b/ui/app/components/transaction-status/transaction-status.component.js @@ -11,6 +11,7 @@ import { CONFIRMED_STATUS, FAILED_STATUS, DROPPED_STATUS, + CANCELLED_STATUS, } from '../../constants/transactions' const statusToClassNameHash = { @@ -22,6 +23,7 @@ const statusToClassNameHash = { [CONFIRMED_STATUS]: 'transaction-status--confirmed', [FAILED_STATUS]: 'transaction-status--failed', [DROPPED_STATUS]: 'transaction-status--dropped', + [CANCELLED_STATUS]: 'transaction-status--failed', } const statusToTextHash = { @@ -49,7 +51,10 @@ export default class TransactionStatus extends PureComponent { return (
- + { statusText }
diff --git a/ui/app/constants/transactions.js b/ui/app/constants/transactions.js index 2dc061091..d0a819b9b 100644 --- a/ui/app/constants/transactions.js +++ b/ui/app/constants/transactions.js @@ -6,6 +6,7 @@ export const SUBMITTED_STATUS = 'submitted' export const CONFIRMED_STATUS = 'confirmed' export const FAILED_STATUS = 'failed' export const DROPPED_STATUS = 'dropped' +export const CANCELLED_STATUS = 'cancelled' export const TOKEN_METHOD_TRANSFER = 'transfer' export const TOKEN_METHOD_APPROVE = 'approve' @@ -17,7 +18,7 @@ export const APPROVE_ACTION_KEY = 'approve' export const SEND_TOKEN_ACTION_KEY = 'sentTokens' export const TRANSFER_FROM_ACTION_KEY = 'transferFrom' export const SIGNATURE_REQUEST_KEY = 'signatureRequest' -export const UNKNOWN_FUNCTION_KEY = 'unknownFunction' +export const CONTRACT_INTERACTION_KEY = 'contractInteraction' export const CANCEL_ATTEMPT_ACTION_KEY = 'cancelAttempt' export const TRANSACTION_TYPE_SHAPESHIFT = 'shapeshift' diff --git a/ui/app/ducks/gas.duck.js b/ui/app/ducks/gas.duck.js index 8db24cc83..83c236d81 100644 --- a/ui/app/ducks/gas.duck.js +++ b/ui/app/ducks/gas.duck.js @@ -4,6 +4,9 @@ import { loadLocalStorageData, saveLocalStorageData, } from '../../lib/local-storage-helpers' +import { + decGWEIToHexWEI, +} from '../helpers/conversions.util' // Actions const BASIC_GAS_ESTIMATE_LOADING_FINISHED = 'metamask/gas/BASIC_GAS_ESTIMATE_LOADING_FINISHED' @@ -403,6 +406,17 @@ export function fetchGasEstimates (blockTime) { } } +export function setCustomGasPriceForRetry (newPrice) { + return (dispatch) => { + if (newPrice !== '0x0') { + dispatch(setCustomGasPrice(newPrice)) + } else { + const { fast } = loadLocalStorageData('BASIC_PRICE_ESTIMATES') + dispatch(setCustomGasPrice(decGWEIToHexWEI(fast))) + } + } +} + export function setBasicGasEstimateData (basicGasEstimateData) { return { type: SET_BASIC_GAS_ESTIMATE_DATA, diff --git a/ui/app/helpers/transactions.util.js b/ui/app/helpers/transactions.util.js index 2f4b1d095..0f1ed70a3 100644 --- a/ui/app/helpers/transactions.util.js +++ b/ui/app/helpers/transactions.util.js @@ -2,6 +2,10 @@ import ethUtil from 'ethereumjs-util' import MethodRegistry from 'eth-method-registry' import abi from 'human-standard-token-abi' import abiDecoder from 'abi-decoder' +import { + TRANSACTION_TYPE_CANCEL, + TRANSACTION_STATUS_CONFIRMED, +} from '../../../app/scripts/controllers/transactions/enums' import { TOKEN_METHOD_TRANSFER, @@ -13,7 +17,7 @@ import { SEND_TOKEN_ACTION_KEY, TRANSFER_FROM_ACTION_KEY, SIGNATURE_REQUEST_KEY, - UNKNOWN_FUNCTION_KEY, + CONTRACT_INTERACTION_KEY, CANCEL_ATTEMPT_ACTION_KEY, } from '../constants/transactions' @@ -87,7 +91,7 @@ export async function getTransactionActionKey (transaction, methodData) { const methodName = name && name.toLowerCase() if (!methodName) { - return UNKNOWN_FUNCTION_KEY + return CONTRACT_INTERACTION_KEY } switch (methodName) { @@ -148,12 +152,16 @@ export function sumHexes (...args) { * @returns {string} */ export function getStatusKey (transaction) { - const { txReceipt: { status } = {} } = transaction + const { txReceipt: { status: receiptStatus } = {}, type, status } = transaction // There was an on-chain failure - if (status === '0x0') { + if (receiptStatus === '0x0') { return 'failed' } + if (status === TRANSACTION_STATUS_CONFIRMED && type === TRANSACTION_TYPE_CANCEL) { + return 'cancelled' + } + return transaction.status } diff --git a/ui/app/selectors/transactions.js b/ui/app/selectors/transactions.js index 479002794..301e8d11f 100644 --- a/ui/app/selectors/transactions.js +++ b/ui/app/selectors/transactions.js @@ -1,16 +1,44 @@ import { createSelector } from 'reselect' -import { valuesFor } from '../util' import { UNAPPROVED_STATUS, APPROVED_STATUS, SUBMITTED_STATUS, + CONFIRMED_STATUS, } from '../constants/transactions' +import { + TRANSACTION_TYPE_CANCEL, + TRANSACTION_TYPE_RETRY, +} from '../../../app/scripts/controllers/transactions/enums' +import { hexToDecimal } from '../helpers/conversions.util' import { selectedTokenAddressSelector } from './tokens' +import txHelper from '../../lib/tx-helper' export const shapeShiftTxListSelector = state => state.metamask.shapeShiftTxList export const unapprovedMsgsSelector = state => state.metamask.unapprovedMsgs export const selectedAddressTxListSelector = state => state.metamask.selectedAddressTxList +export const unapprovedPersonalMsgsSelector = state => state.metamask.unapprovedPersonalMsgs +export const unapprovedTypedMessagesSelector = state => state.metamask.unapprovedTypedMessages +export const networkSelector = state => state.metamask.network + +export const unapprovedMessagesSelector = createSelector( + unapprovedMsgsSelector, + unapprovedPersonalMsgsSelector, + unapprovedTypedMessagesSelector, + networkSelector, + ( + unapprovedMsgs = {}, + unapprovedPersonalMsgs = {}, + unapprovedTypedMessages = {}, + network + ) => txHelper( + {}, + unapprovedMsgs, + unapprovedPersonalMsgs, + unapprovedTypedMessages, + network + ) || [] +) const pendingStatusHash = { [UNAPPROVED_STATUS]: true, @@ -18,14 +46,18 @@ const pendingStatusHash = { [SUBMITTED_STATUS]: true, } +const priorityStatusHash = { + ...pendingStatusHash, + [CONFIRMED_STATUS]: true, +} + export const transactionsSelector = createSelector( selectedTokenAddressSelector, - unapprovedMsgsSelector, + unapprovedMessagesSelector, shapeShiftTxListSelector, selectedAddressTxListSelector, - (selectedTokenAddress, unapprovedMsgs = {}, shapeShiftTxList = [], transactions = []) => { - const unapprovedMsgsList = valuesFor(unapprovedMsgs) - const txsToRender = transactions.concat(unapprovedMsgsList, shapeShiftTxList) + (selectedTokenAddress, unapprovedMessages = [], shapeShiftTxList = [], transactions = []) => { + const txsToRender = transactions.concat(unapprovedMessages, shapeShiftTxList) return selectedTokenAddress ? txsToRender @@ -36,10 +68,193 @@ export const transactionsSelector = createSelector( } ) -export const pendingTransactionsSelector = createSelector( +/** + * @name insertOrderedNonce + * @private + * @description Inserts (mutates) a nonce into an array of ordered nonces, sorted in ascending + * order. + * @param {string[]} nonces - Array of nonce strings in hex + * @param {string} nonceToInsert - Nonce string in hex to be inserted into the array of nonces. + * @returns {string[]} + */ +const insertOrderedNonce = (nonces, nonceToInsert) => { + let insertIndex = nonces.length + + for (let i = 0; i < nonces.length; i++) { + const nonce = nonces[i] + + if (Number(hexToDecimal(nonce)) < Number(hexToDecimal(nonceToInsert))) { + insertIndex = i + break + } + } + + nonces.splice(insertIndex, 0, nonceToInsert) +} + +/** + * @name insertTransactionByTime + * @private + * @description Inserts (mutates) a transaction object into an array of ordered transactions, sorted + * in ascending order by time. + * @param {Object[]} transactions - Array of transaction objects. + * @param {Object} transaction - Transaction object to be inserted into the array of transactions. + * @returns {Object[]} + */ +const insertTransactionByTime = (transactions, transaction) => { + const { time } = transaction + + let insertIndex = transactions.length + + for (let i = 0; i < transactions.length; i++) { + const tx = transactions[i] + + if (tx.time > time) { + insertIndex = i + break + } + } + + transactions.splice(insertIndex, 0, transaction) +} + +/** + * Contains transactions and properties associated with those transactions of the same nonce. + * @typedef {Object} transactionGroup + * @property {string} nonce - The nonce that the transactions within this transactionGroup share. + * @property {Object[]} transactions - An array of transaction (txMeta) objects. + * @property {Object} initialTransaction - The transaction (txMeta) with the lowest "time". + * @property {Object} primaryTransaction - Either the latest transaction or the confirmed + * transaction. + * @property {boolean} hasRetried - True if a transaction in the group was a retry transaction. + * @property {boolean} hasCancelled - True if a transaction in the group was a cancel transaction. + */ + +/** + * @name insertTransactionGroupByTime + * @private + * @description Inserts (mutates) a transactionGroup object into an array of ordered + * transactionGroups, sorted in ascending order by nonce. + * @param {transactionGroup[]} transactionGroups - Array of transactionGroup objects. + * @param {transactionGroup} transactionGroup - transactionGroup object to be inserted into the + * array of transactionGroups. + * @returns {transactionGroup[]} + */ +const insertTransactionGroupByTime = (transactionGroups, transactionGroup) => { + const { primaryTransaction: { time } = {} } = transactionGroup + + let insertIndex = transactionGroups.length + + for (let i = 0; i < transactionGroups.length; i++) { + const txGroup = transactionGroups[i] + + if (txGroup.time > time) { + insertIndex = i + break + } + } + + transactionGroups.splice(insertIndex, 0, transactionGroup) +} + +/** + * @name nonceSortedTransactionsSelector + * @description Returns an array of transactionGroups sorted by nonce in ascending order. + * @returns {transactionGroup[]} + */ +export const nonceSortedTransactionsSelector = createSelector( transactionsSelector, + (transactions = []) => { + const unapprovedTransactionGroups = [] + const orderedNonces = [] + const nonceToTransactionsMap = {} + + transactions.forEach(transaction => { + const { txParams: { nonce } = {}, status, type, time: txTime } = transaction + + if (typeof nonce === 'undefined') { + const transactionGroup = { + transactions: [transaction], + initialTransaction: transaction, + primaryTransaction: transaction, + hasRetried: false, + hasCancelled: false, + } + + insertTransactionGroupByTime(unapprovedTransactionGroups, transactionGroup) + } else if (nonce in nonceToTransactionsMap) { + const nonceProps = nonceToTransactionsMap[nonce] + insertTransactionByTime(nonceProps.transactions, transaction) + + if (status in priorityStatusHash) { + const { primaryTransaction: { time: primaryTxTime = 0 } = {} } = nonceProps + + if (status === CONFIRMED_STATUS || txTime > primaryTxTime) { + nonceProps.primaryTransaction = transaction + } + } + + const { initialTransaction: { time: initialTxTime = 0 } = {} } = nonceProps + + // Used to display the transaction action, since we don't want to overwrite the action if + // it was replaced with a cancel attempt transaction. + if (txTime < initialTxTime) { + nonceProps.initialTransaction = transaction + } + + if (type === TRANSACTION_TYPE_RETRY) { + nonceProps.hasRetried = true + } + + if (type === TRANSACTION_TYPE_CANCEL) { + nonceProps.hasCancelled = true + } + } else { + nonceToTransactionsMap[nonce] = { + nonce, + transactions: [transaction], + initialTransaction: transaction, + primaryTransaction: transaction, + hasRetried: transaction.type === TRANSACTION_TYPE_RETRY, + hasCancelled: transaction.type === TRANSACTION_TYPE_CANCEL, + } + + insertOrderedNonce(orderedNonces, nonce) + } + }) + + const orderedTransactionGroups = orderedNonces.map(nonce => nonceToTransactionsMap[nonce]) + return unapprovedTransactionGroups.concat(orderedTransactionGroups) + } +) + +/** + * @name nonceSortedPendingTransactionsSelector + * @description Returns an array of transactionGroups where transactions are still pending sorted by + * nonce in descending order. + * @returns {transactionGroup[]} + */ +export const nonceSortedPendingTransactionsSelector = createSelector( + nonceSortedTransactionsSelector, (transactions = []) => ( - transactions.filter(transaction => transaction.status in pendingStatusHash).reverse() + transactions + .filter(({ primaryTransaction }) => primaryTransaction.status in pendingStatusHash) + .reverse() + ) +) + +/** + * @name nonceSortedCompletedTransactionsSelector + * @description Returns an array of transactionGroups where transactions are confirmed sorted by + * nonce in descending order. + * @returns {transactionGroup[]} + */ +export const nonceSortedCompletedTransactionsSelector = createSelector( + nonceSortedTransactionsSelector, + (transactions = []) => ( + transactions.filter(({ primaryTransaction }) => { + return !(primaryTransaction.status in pendingStatusHash) + }) ) ) @@ -49,10 +264,3 @@ export const submittedPendingTransactionsSelector = createSelector( transactions.filter(transaction => transaction.status === SUBMITTED_STATUS) ) ) - -export const completedTransactionsSelector = createSelector( - transactionsSelector, - (transactions = []) => ( - transactions.filter(transaction => !(transaction.status in pendingStatusHash)) - ) -) diff --git a/ui/app/util.js b/ui/app/util.js index b19a028cc..28f027e26 100644 --- a/ui/app/util.js +++ b/ui/app/util.js @@ -8,8 +8,8 @@ const GWEI_FACTOR = new ethUtil.BN(1e9) const MIN_GAS_PRICE_BN = MIN_GAS_PRICE_GWEI_BN.mul(GWEI_FACTOR) // formatData :: ( date: ) -> String -function formatDate (date) { - return vreme.format(new Date(date), '3/16/2014 at 14:30') +function formatDate (date, format = '3/16/2014 at 14:30') { + return vreme.format(new Date(date), format) } var valueTable = { diff --git a/ui/lib/tx-helper.js b/ui/lib/tx-helper.js index 0a6f55a63..260dbaa39 100644 --- a/ui/lib/tx-helper.js +++ b/ui/lib/tx-helper.js @@ -21,7 +21,7 @@ module.exports = function (unapprovedTxs, unapprovedMsgs, personalMsgs, typedMes allValues = allValues.concat(typedValues) allValues = allValues.sort((a, b) => { - return a.time > b.time + return a.time - b.time }) return allValues