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