1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 09:57:02 +01:00

Group transactions by nonce (#5886)

This commit is contained in:
Alexander Tseung 2018-12-09 12:48:06 -08:00 committed by GitHub
parent 575fb607c3
commit d8ab9cc002
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1211 additions and 376 deletions

View File

@ -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"
},

3
app/images/icons/cancelled.svg Executable file
View File

@ -0,0 +1,3 @@
<svg width="7" height="8" viewBox="0 0 7 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.97959 1.19291C1.66717 0.880488 1.16063 0.880488 0.848215 1.19291C0.535796 1.50533 0.535796 2.01186 0.848215 2.32428L2.52394 4L0.848215 5.67572C0.535796 5.98814 0.535796 6.49467 0.848215 6.80709C1.16063 7.11951 1.66717 7.11951 1.97959 6.80709L3.65531 5.13137L5.33122 6.80728C5.64364 7.1197 6.15017 7.1197 6.46259 6.80728C6.77501 6.49486 6.77501 5.98833 6.46259 5.67591L4.78668 4L6.46259 2.32409C6.77501 2.01167 6.77501 1.50514 6.46259 1.19272C6.15017 0.880297 5.64364 0.880297 5.33122 1.19272L3.65531 2.86863L1.97959 1.19291Z" fill="#F9FBFF"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@ -0,0 +1,3 @@
<svg width="7" height="5" viewBox="0 0 7 5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.97989 0.212475C6.27337 0.495775 6.27337 0.955095 5.97989 1.23839L2.16061 4.92513L0.220114 3.05198C-0.0733712 2.76868 -0.0733712 2.30936 0.220114 2.02606C0.513599 1.74276 0.989432 1.74276 1.28292 2.02606L2.16061 2.87329L4.91708 0.212475C5.21057 -0.070825 5.6864 -0.070825 5.97989 0.212475Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@ -0,0 +1,4 @@
<svg width="2" height="8" viewBox="0 0 2 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="2" height="5" rx="1" fill="white"/>
<rect y="6" width="2" height="2" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 203 B

3
app/images/icons/new.svg Executable file
View File

@ -0,0 +1,3 @@
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.76923 4.2003C1.3274 4.2003 0.969231 4.55847 0.969231 5.0003C0.969231 5.44213 1.3274 5.8003 1.76923 5.8003H4.20048V8.23077C4.20048 8.6726 4.55865 9.03077 5.00048 9.03077C5.44231 9.03077 5.80048 8.6726 5.80048 8.23077V5.8003H8.23077C8.6726 5.8003 9.03077 5.44213 9.03077 5.0003C9.03077 4.55847 8.6726 4.2003 8.23077 4.2003L5.80048 4.2003L5.80048 1.76923C5.80048 1.3274 5.44231 0.969229 5.00048 0.969229C4.55865 0.969229 4.20048 1.3274 4.20048 1.76923V4.2003H1.76923Z" fill="#F9FBFF"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

7
app/images/icons/retry.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

3
app/images/icons/submitted.svg Executable file
View File

@ -0,0 +1,3 @@
<svg width="7" height="6" viewBox="0 0 7 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.24834 0.0498428C5.69016 0.0498428 6.04834 0.408016 6.04834 0.849844L6.04834 4.84984C6.04834 5.29167 5.69016 5.64984 5.24834 5.64984C4.80651 5.64984 4.44834 5.29167 4.44834 4.84984V2.68278L1.56558 5.56553C1.25316 5.87795 0.746632 5.87795 0.434212 5.56553C0.121793 5.25311 0.121793 4.74658 0.434212 4.43416L3.21853 1.64984L1.24834 1.64984C0.806507 1.64984 0.448335 1.29167 0.448335 0.849844C0.448335 0.408016 0.806507 0.0498428 1.24834 0.0498428L5.24834 0.0498428Z" fill="#F9FBFF"/>
</svg>

After

Width:  |  Height:  |  Size: 632 B

View File

@ -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,
}

View File

@ -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

View File

@ -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) {
try {
await this.txController.createCancelTransaction(originalTxId, customGasPrice)
const state = await this.getState()
return state
} catch (error) {
throw error
}
}
async createSpeedUpTransaction (originalTxId, customGasPrice, cb) {

View File

@ -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)

View File

@ -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')
}

View File

@ -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)

View File

@ -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),
}
}

View File

@ -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}

View File

@ -1,12 +1,13 @@
.sender-to-recipient {
&--default {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
border-bottom: 1px solid $geyser;
position: relative;
flex: 0 0 auto;
&--default {
border-bottom: 1px solid $geyser;
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;
}
}
}
}

View File

@ -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
? (
<div className="sender-to-recipient__arrow-container">
<img
height={20}
src="./images/caret-right.svg"
/>
</div>
) : (
<div className="sender-to-recipient__arrow-container">
<div className="sender-to-recipient__arrow-circle">
<img
@ -146,6 +140,13 @@ export default class SenderToRecipient extends PureComponent {
/>
</div>
</div>
) : (
<div className="sender-to-recipient__arrow-container">
<img
height={20}
src="./images/caret-right.svg"
/>
</div>
)
}
@ -154,7 +155,7 @@ export default class SenderToRecipient extends PureComponent {
const checksummedSenderAddress = checksumAddress(senderAddress)
return (
<div className={classnames(variantHash[variant])}>
<div className={classnames('sender-to-recipient', variantHash[variant])}>
<div
className={classnames('sender-to-recipient__party sender-to-recipient__party--sender')}
onClick={() => {

View File

@ -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'

View File

@ -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;
}

View File

@ -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(
<TransactionActivityLog
transaction={transaction}
activities={activities}
className="test-class"
inlineRetryIndex={-1}
inlineCancelIndex={-1}
nativeCurrency="ETH"
onCancel={() => {}}
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(
<TransactionActivityLog
activities={activities}
className="test-class"
inlineRetryIndex={2}
inlineCancelIndex={3}
nativeCurrency="ETH"
onCancel={() => {}}
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)
})
})

View File

@ -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)
})
})

View File

@ -0,0 +1 @@
export { default } from './transaction-activity-log-icon.component'

View File

@ -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 (
<div className={classnames('transaction-activity-log-icon', className)}>
{
imagePath && (
<img
src={imagePath}
height={9}
width={9}
/>
)
}
</div>
)
}
}

View File

@ -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
? (
<div
className="transaction-activity-log__action-link"
onClick={() => onRetry(id)}
>
{ t('speedUpTransaction') }
</div>
) : 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
? (
<div
className="transaction-activity-log__action-link"
onClick={() => onCancel(id)}
>
{ t('speedUpCancellation') }
</div>
) : 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,32 +93,37 @@ export default class TransactionActivityLog extends PureComponent {
key={index}
className="transaction-activity-log__activity"
>
<div className="transaction-activity-log__activity-icon" />
<TransactionActivityLogIcon
className="transaction-activity-log__activity-icon"
eventKey={eventKey}
/>
<div className="transaction-activity-log__entry-container">
<div
className="transaction-activity-log__activity-text"
title={activityText}
onClick={() => this.handleActivityClick(hash)}
>
{ activityText }
</div>
{ this.renderInlineRetry(index, activity) }
{ this.renderInlineCancel(index, activity) }
</div>
</div>
)
}
render () {
const { t } = this.context
const { className } = this.props
const { activities } = this.state
const { className, activities } = this.props
return (
<div className={classnames('transaction-activity-log', className)}>
<Card
title={t('activityLog')}
className="transaction-activity-log__card"
>
<div className="transaction-activity-log__title">
{ t('activityLog') }
</div>
<div className="transaction-activity-log__activities-container">
{ activities.map((activity, index) => this.renderActivity(activity, index)) }
</div>
</Card>
</div>
)
}

View File

@ -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'

View File

@ -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)

View File

@ -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,
/**
* @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
let cachedGasLimit = '0x0'
let cachedGasPrice = '0x0'
const historyActivities = history.reduce((acc, base, index) => {
// First history item should be transaction creation
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,
})
}
}
export function getActivities (transaction) {
const { history = [], txReceipt: { status } = {} } = transaction
const historyActivities = history.reduce((acc, base) => {
// 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))
// 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)
}

View File

@ -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 {

View File

@ -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)
})
})

View File

@ -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,10 +36,9 @@ export default class TransactionBreakdown extends PureComponent {
return (
<div className={classnames('transaction-breakdown', className)}>
<Card
title={t('transaction')}
className="transaction-breakdown__card"
>
<div className="transaction-breakdown__title">
{ t('transaction') }
</div>
<TransactionBreakdownRow title={t('amount')}>
<UserPreferencedCurrencyDisplay
className="transaction-breakdown__value"
@ -93,7 +91,6 @@ export default class TransactionBreakdown extends PureComponent {
/>
</div>
</TransactionBreakdownRow>
</Card>
</div>
)
}

View File

@ -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;
}
}
}

View File

@ -23,9 +23,15 @@ describe('TransactionListItemDetails Component', () => {
},
}
const transactionGroup = {
transactions: [transaction],
primaryTransaction: transaction,
initialTransaction: transaction,
}
const wrapper = shallow(
<TransactionListItemDetails
transaction={transaction}
transactionGroup={transactionGroup}
/>,
{ 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(
<TransactionListItemDetails
transaction={transaction}
transactionGroup={transactionGroup}
showRetry={true}
/>,
{ context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } }

View File

@ -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 (
<div className="transaction-list-item-details">
<div className="transaction-list-item-details__header">
<div>Details</div>
<div>{ t('details') }</div>
<div className="transaction-list-item-details__header-buttons">
{
showRetry && (
@ -88,9 +89,10 @@ export default class TransactionListItemDetails extends PureComponent {
</Tooltip>
</div>
</div>
<div className="transaction-list-item-details__body">
<div className="transaction-list-item-details__sender-to-recipient-container">
<SenderToRecipient
variant={CARDS_VARIANT}
variant={FLAT_VARIANT}
addressOnly
recipientAddress={to}
senderAddress={from}
@ -102,11 +104,14 @@ export default class TransactionListItemDetails extends PureComponent {
className="transaction-list-item-details__transaction-breakdown"
/>
<TransactionActivityLog
transaction={transaction}
transactionGroup={transactionGroup}
className="transaction-list-item-details__transaction-activity-log"
onCancel={onCancel}
onRetry={onRetry}
/>
</div>
</div>
</div>
)
}
}

View File

@ -117,12 +117,6 @@
}
}
&__details-container {
padding: 8px 16px 16px;
background: #f3f4f7;
width: 100%;
}
&__expander {
max-height: 0px;
width: 100%;

View File

@ -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 {
</div>
<TransactionStatus
className="transaction-list-item__status"
statusKey={getStatusKey(transaction)}
statusKey={getStatusKey(primaryTransaction)}
title={(
(transaction.err && transaction.err.rpc)
? transaction.err.rpc.message
: transaction.err && transaction.err.message
(primaryTransaction.err && primaryTransaction.err.rpc)
? primaryTransaction.err.rpc.message
: primaryTransaction.err && primaryTransaction.err.message
)}
/>
{ this.renderPrimaryCurrency() }
@ -173,7 +189,7 @@ export default class TransactionListItem extends PureComponent {
showTransactionDetails && (
<div className="transaction-list-item__details-container">
<TransactionListItemDetails
transaction={transaction}
transactionGroup={transactionGroup}
onRetry={this.handleRetry}
showRetry={showRetry && methodData.done}
onCancel={this.handleCancel}

View File

@ -6,6 +6,7 @@ import TransactionListItem from './transaction-list-item.component'
import { setSelectedToken, showModal, showSidebar } from '../../actions'
import { hexToDecimal } from '../../helpers/conversions.util'
import { getTokenData } from '../../helpers/transactions.util'
import { increaseLastGasPrice } from '../../helpers/confirm-transaction/util'
import { formatDate } from '../../util'
import {
fetchBasicGasAndTimeEstimates,
@ -14,26 +15,13 @@ import {
setCustomGasLimit,
} from '../../ducks/gas.duck'
const mapStateToProps = (state, ownProps) => {
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)

View File

@ -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 (
<div className="transaction-list__transactions">
{
pendingTransactions.length > 0 && (
pendingLength > 0 && (
<div className="transaction-list__pending-transactions">
<div className="transaction-list__header">
{ `${t('queue')} (${pendingTransactions.length})` }
</div>
{
pendingTransactions.map((transaction, index) => (
this.renderTransaction(transaction, index, true)
pendingTransactions.map((transactionGroup, index) => (
this.renderTransaction(transactionGroup, index, true, index === pendingLength - 1)
))
}
</div>
@ -68,8 +74,8 @@ export default class TransactionList extends PureComponent {
</div>
{
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
? (
<ShapeShiftTransactionListItem
{ ...transaction }
{ ...transactions[0] }
key={`shapeshift${index}`}
/>
) : (
<TransactionListItem
transaction={transaction}
key={transaction.id}
showRetry={this.shouldShowRetry(transaction)}
showCancel={showCancel}
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${index}`}
showRetry={isPendingTx && this.shouldShowRetry(transactionGroup, isEarliestNonce)}
showCancel={isPendingTx && this.shouldShowCancel(transactionGroup)}
token={selectedToken}
assetImages={assetImages}
/>

View File

@ -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),

View File

@ -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;
}
}
}

View File

@ -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')
})
})

View File

@ -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 (
<div className={classnames('transaction-status', className, statusToClassNameHash[statusKey])}>
<Tooltip position="top" title={title}>
<Tooltip
position="top"
title={title}
>
{ statusText }
</Tooltip>
</div>

View File

@ -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'

View File

@ -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,

View File

@ -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
}

View File

@ -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))
)
)

View File

@ -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: <Unix Timestamp> ) -> 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 = {

View File

@ -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