diff options
134 files changed, 5293 insertions, 814 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d86fcd713..4ad52b795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Current Master + - Remove rejected transactions from transaction history + ## 4.8.0 Thur Jun 14 2018 - [#4513](https://github.com/MetaMask/metamask-extension/pull/4513): Attempting to import an empty private key will now show a clear error. @@ -9,7 +9,7 @@ If you're a user seeking support, [here is our support site](https://metamask.he [Mission Statement](./MISSION.md) -[Internal documentation](./docs/jsdocs) +[Internal documentation](./docs#documentation) ## Developing Compatible Dapps diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 14d2f923a..c40a8c3b6 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -43,6 +43,9 @@ "message": "MetaMask", "description": "The name of the application" }, + "approve": { + "message": "Approve" + }, "approved": { "message": "Approved" }, @@ -95,6 +98,9 @@ "buyCoinbaseExplainer": { "message": "Coinbase is the world’s most popular way to buy and sell bitcoin, ethereum, and litecoin." }, + "bytes": { + "message": "Bytes" + }, "ok": { "message": "Ok" }, @@ -176,6 +182,9 @@ "copyContractAddress": { "message": "Copy Contract Address" }, + "copyAddress": { + "message": "Copy address to clipboard" + }, "copyToClipboard": { "message": "Copy to clipboard" }, @@ -307,6 +316,9 @@ "enterPasswordContinue": { "message": "Enter password to continue" }, + "parameters": { + "message": "Parameters" + }, "passwordNotLongEnough": { "message": "Password not long enough" }, @@ -351,6 +363,9 @@ "fromShapeShift": { "message": "From ShapeShift" }, + "functionType": { + "message": "Function Type" + }, "gas": { "message": "Gas", "description": "Short indication of gas cost" @@ -406,6 +421,9 @@ "hereList": { "message": "Here's a list!!!!" }, + "hexData": { + "message": "Hex Data" + }, "hide": { "message": "Hide" }, @@ -618,6 +636,9 @@ "message": "or", "description": "choice between creating or importing a new account" }, + "origin": { + "message": "Origin" + }, "password": { "message": "Password" }, @@ -962,6 +983,9 @@ "transactionNumber": { "message": "Transaction Number" }, + "transfer": { + "message": "Transfer" + }, "transfers": { "message": "Transfers" }, diff --git a/app/images/alert-red.svg b/app/images/alert-red.svg new file mode 100644 index 000000000..ac5b30e27 --- /dev/null +++ b/app/images/alert-red.svg @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch --> + <title>Artboard Copy</title> + <desc>Created with Sketch.</desc> + <defs></defs> + <g id="Artboard-Copy" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Group-48"> + <circle id="Oval" fill="#D0021B" cx="8" cy="8" r="8"></circle> + <rect id="Rectangle-41" fill="#FFFFFF" x="7" y="3" width="2" height="7" rx="1"></rect> + <rect id="Rectangle-41" fill="#FFFFFF" x="7" y="11" width="2" height="2" rx="1"></rect> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/images/alert.svg b/app/images/alert.svg new file mode 100644 index 000000000..534eda194 --- /dev/null +++ b/app/images/alert.svg @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="29px" height="29px" viewBox="0 0 29 29" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: sketchtool 50.2 (55047) - http://www.bohemiancoding.com/sketch --> + <title>7414FFD8-B28A-4593-9D7E-19E73D687B50</title> + <desc>Created with sketchtool.</desc> + <defs></defs> + <g id="Action-Screens" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Approve---insufficient-amount" transform="translate(-69.000000, -166.000000)"> + <g id="Group-7" transform="translate(53.000000, 51.000000)"> + <g id="Group-34" transform="translate(0.000000, 91.000000)"> + <g id="alert" transform="translate(16.000000, 24.000000)"> + <circle id="Oval" fill="#605A1C" cx="14.5" cy="14.5" r="14.5"></circle> + <path d="M16,16.8282967 L14,16.8282967 L14,7 L16,7 L16,16.8282967 Z M16,21 L14,21 L14,19 L16,19 L16,21 Z" id="!" fill="#FFFCDB"></path> + </g> + </g> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/images/caret-left.svg b/app/images/caret-left.svg new file mode 100644 index 000000000..0ea266161 --- /dev/null +++ b/app/images/caret-left.svg @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="9px" height="15px" viewBox="0 0 9 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: sketchtool 50.2 (55047) - http://www.bohemiancoding.com/sketch --> + <title>8439120D-5704-4273-B416-FEE134322584</title> + <desc>Created with sketchtool.</desc> + <defs></defs> + <g id="Action-Screens" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Approve---insufficient-amount" transform="translate(-75.000000, -69.000000)" stroke="#3099F2" stroke-width="2"> + <g id="Group-7" transform="translate(53.000000, 51.000000)"> + <g id="cancel" transform="translate(24.000000, 14.000000)"> + <g id="Group"> + <polyline id="Path-8" points="6.1263881 18.0633906 0 11.6306831 6.31493631 5"></polyline> + </g> + </g> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/scripts/controllers/network/enums.js b/app/scripts/controllers/network/enums.js index 9da7f309c..3190eb37c 100644 --- a/app/scripts/controllers/network/enums.js +++ b/app/scripts/controllers/network/enums.js @@ -4,6 +4,7 @@ const KOVAN = 'kovan' const MAINNET = 'mainnet' const LOCALHOST = 'localhost' +const MAINNET_CODE = 1 const ROPSTEN_CODE = 3 const RINKEYBY_CODE = 4 const KOVAN_CODE = 42 @@ -13,13 +14,13 @@ const RINKEBY_DISPLAY_NAME = 'Rinkeby' const KOVAN_DISPLAY_NAME = 'Kovan' const MAINNET_DISPLAY_NAME = 'Main Ethereum Network' - module.exports = { ROPSTEN, RINKEBY, KOVAN, MAINNET, LOCALHOST, + MAINNET_CODE, ROPSTEN_CODE, RINKEYBY_CODE, KOVAN_CODE, diff --git a/app/scripts/controllers/transactions/tx-state-manager.js b/app/scripts/controllers/transactions/tx-state-manager.js index 0aae4774b..28a18ca2e 100644 --- a/app/scripts/controllers/transactions/tx-state-manager.js +++ b/app/scripts/controllers/transactions/tx-state-manager.js @@ -288,6 +288,7 @@ class TransactionStateManager extends EventEmitter { */ setTxStatusRejected (txId) { this._setTxStatus(txId, 'rejected') + this._removeTx(txId) } /** @@ -422,6 +423,11 @@ class TransactionStateManager extends EventEmitter { _saveTxList (transactions) { this.store.updateState({ transactions }) } + + _removeTx (txId) { + const transactionList = this.getFullTxList() + this._saveTxList(transactionList.filter((txMeta) => txMeta.id !== txId)) + } } module.exports = TransactionStateManager diff --git a/app/scripts/migrations/027.js b/app/scripts/migrations/027.js new file mode 100644 index 000000000..d6ebef580 --- /dev/null +++ b/app/scripts/migrations/027.js @@ -0,0 +1,35 @@ +// next version number +const version = 27 + +/* + +normalizes txParams on unconfirmed txs + +*/ +const clone = require('clone') + +module.exports = { + version, + + migrate: async function (originalVersionedData) { + const versionedData = clone(originalVersionedData) + versionedData.meta.version = version + const state = versionedData.data + const newState = transformState(state) + versionedData.data = newState + return versionedData + }, +} + +function transformState (state) { + const newState = state + + if (newState.TransactionController) { + if (newState.TransactionController.transactions) { + const transactions = newState.TransactionController.transactions + newState.TransactionController.transactions = transactions.filter((txMeta) => txMeta.status !== 'rejected') + } + } + + return newState +} diff --git a/development/backGroundConnectionModifiers.js b/development/backGroundConnectionModifiers.js index 665f72898..aee68854b 100644 --- a/development/backGroundConnectionModifiers.js +++ b/development/backGroundConnectionModifiers.js @@ -10,7 +10,7 @@ module.exports = { signPersonalMessage: (msgData, cb) => { const stateUpdate = { unapprovedPersonalMsgs: {}, - unapprovedPersonalMsgsCount: 0, + unapprovedPersonalMsgCount: 0, } return cb(null, stateUpdate) }, diff --git a/development/states/confirm-new-ui.json b/development/states/confirm-new-ui.json index 71ccbd96c..2c2e17704 100644 --- a/development/states/confirm-new-ui.json +++ b/development/states/confirm-new-ui.json @@ -156,5 +156,23 @@ "fromDropdownOpen": false, "toDropdownOpen": false, "errors": {} + }, + "confirmTransaction": { + "txData": {}, + "tokenData": {}, + "methodData": {}, + "tokenProps": { + "tokenDecimals": "", + "tokenSymbol": "" + }, + "fiatTransactionAmount": "", + "fiatTransactionFee": "", + "fiatTransactionTotal": "", + "ethTransactionAmount": "", + "ethTransactionFee": "", + "ethTransactionTotal": "", + "hexGasTotal": "", + "nonce": "", + "fetchingMethodData": false } } diff --git a/development/states/confirm-sig-requests.json b/development/states/confirm-sig-requests.json index 3c9caafb0..829f513a8 100644 --- a/development/states/confirm-sig-requests.json +++ b/development/states/confirm-sig-requests.json @@ -73,7 +73,7 @@ "from": "0x0d0c7188d9c72b019a5da9bca0d127680c22e658" }, "status": "unapproved", - "time": 1537889069339, + "time": 1537889070000, "type": "eth_sign" } }, @@ -86,11 +86,11 @@ "from": "0x0d0c7188d9c72b019a5da9bca0d127680c22e659" }, "status": "unapproved", - "time": 1517889069339, + "time": 1537889065000, "type": "personal_sign" } }, - "unapprovedPersonalMsgCount": 0, + "unapprovedPersonalMsgCount": 1 , "unapprovedTypedMessages": { "8997167822566869": { "id": 8997167822566869, @@ -102,7 +102,7 @@ "from": "0x0d0c7188d9c72b019a5da9bca0d127680c22e659" }, "status": "unapproved", - "time": 1617889069339, + "time": 1537889060000, "type": "eth_signTypedData" } }, @@ -172,5 +172,32 @@ "scrollToBottom": false, "forgottenPassword": null }, - "identities": {} + "identities": {}, + "confirmTransaction": { + "txData": { + "id": 8927167822566864, + "msgParams": { + "data": "0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0", + "from": "0x0d0c7188d9c72b019a5da9bca0d127680c22e658" + }, + "status": "unapproved", + "time": 1537889069339, + "type": "eth_sign" + }, + "tokenData": {}, + "methodData": {}, + "tokenProps": { + "tokenDecimals": "", + "tokenSymbol": "" + }, + "fiatTransactionAmount": "", + "fiatTransactionFee": "", + "fiatTransactionTotal": "", + "ethTransactionAmount": "", + "ethTransactionFee": "", + "ethTransactionTotal": "", + "hexGasTotal": "", + "nonce": "", + "fetchingMethodData": false + } } diff --git a/development/states/currency-localization.json b/development/states/currency-localization.json index 8c8b18a91..6848c0840 100644 --- a/development/states/currency-localization.json +++ b/development/states/currency-localization.json @@ -130,5 +130,23 @@ "scrollToBottom": false, "forgottenPassword": null }, - "identities": {} + "identities": {}, + "confirmTransaction": { + "txData": {}, + "tokenData": {}, + "methodData": {}, + "tokenProps": { + "tokenDecimals": "", + "tokenSymbol": "" + }, + "fiatTransactionAmount": "", + "fiatTransactionFee": "", + "fiatTransactionTotal": "", + "ethTransactionAmount": "", + "ethTransactionFee": "", + "ethTransactionTotal": "", + "hexGasTotal": "", + "nonce": "", + "fetchingMethodData": false + } } diff --git a/development/states/first-time.json b/development/states/first-time.json index f10eefd35..e88ec6d65 100644 --- a/development/states/first-time.json +++ b/development/states/first-time.json @@ -58,5 +58,23 @@ } }, "identities": {}, - "computedBalances": {} + "computedBalances": {}, + "confirmTransaction": { + "txData": {}, + "tokenData": {}, + "methodData": {}, + "tokenProps": { + "tokenDecimals": "", + "tokenSymbol": "" + }, + "fiatTransactionAmount": "", + "fiatTransactionFee": "", + "fiatTransactionTotal": "", + "ethTransactionAmount": "", + "ethTransactionFee": "", + "ethTransactionTotal": "", + "hexGasTotal": "", + "nonce": "", + "fetchingMethodData": false + } } diff --git a/development/states/send-edit.json b/development/states/send-edit.json index b05acbf3b..8e5c25a82 100644 --- a/development/states/send-edit.json +++ b/development/states/send-edit.json @@ -156,5 +156,23 @@ "fromDropdownOpen": false, "toDropdownOpen": false, "errors": {} + }, + "confirmTransaction": { + "txData": {}, + "tokenData": {}, + "methodData": {}, + "tokenProps": { + "tokenDecimals": "", + "tokenSymbol": "" + }, + "fiatTransactionAmount": "", + "fiatTransactionFee": "", + "fiatTransactionTotal": "", + "ethTransactionAmount": "", + "ethTransactionFee": "", + "ethTransactionTotal": "", + "hexGasTotal": "", + "nonce": "", + "fetchingMethodData": false } } diff --git a/development/states/send-new-ui.json b/development/states/send-new-ui.json index b457749ee..ad2ff3d6e 100644 --- a/development/states/send-new-ui.json +++ b/development/states/send-new-ui.json @@ -135,5 +135,23 @@ "fromDropdownOpen": false, "toDropdownOpen": false, "errors": {} + }, + "confirmTransaction": { + "txData": {}, + "tokenData": {}, + "methodData": {}, + "tokenProps": { + "tokenDecimals": "", + "tokenSymbol": "" + }, + "fiatTransactionAmount": "", + "fiatTransactionFee": "", + "fiatTransactionTotal": "", + "ethTransactionAmount": "", + "ethTransactionFee": "", + "ethTransactionTotal": "", + "hexGasTotal": "", + "nonce": "", + "fetchingMethodData": false } } diff --git a/package-lock.json b/package-lock.json index 8b5976bc9..6491f71dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8529,6 +8529,108 @@ "xhr-request-promise": "^0.1.2" } }, + "eth-method-registry": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eth-method-registry/-/eth-method-registry-1.0.0.tgz", + "integrity": "sha1-8Ij3Wdad6f3BK3EEm83GiKMoOLY=", + "requires": { + "ethjs": "^0.3.0" + }, + "dependencies": { + "bn.js": { + "version": "4.11.6", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.6.tgz", + "integrity": "sha1-UzRK2xRhehP26N0s4okF0cC6MhU=" + }, + "ethjs": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/ethjs/-/ethjs-0.3.9.tgz", + "integrity": "sha512-gOQzA3tDUjoLpNONSOALJ/rUFtHi5tXl2mholHasF1cvXhoddqi06yU4OJFJu9AGd6n9v9ywzHlYeIKg1t1hdw==", + "requires": { + "bn.js": "4.11.6", + "ethjs-abi": "0.2.1", + "ethjs-contract": "0.2.2", + "ethjs-filter": "0.1.8", + "ethjs-provider-http": "0.1.6", + "ethjs-query": "0.3.7", + "ethjs-unit": "0.1.6", + "ethjs-util": "0.1.3", + "js-sha3": "0.5.5", + "number-to-bn": "1.7.0" + } + }, + "ethjs-abi": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ethjs-abi/-/ethjs-abi-0.2.1.tgz", + "integrity": "sha1-4KepOn6BFjqUR3utVu3lJKtt5TM=", + "requires": { + "bn.js": "4.11.6", + "js-sha3": "0.5.5", + "number-to-bn": "1.7.0" + } + }, + "ethjs-contract": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ethjs-contract/-/ethjs-contract-0.2.2.tgz", + "integrity": "sha512-xxPqEjsULQ/QNWuvX6Ako0PGs5RxALA8N/H3+boLvnaXDFZVGpD7H63H1gBCRTZyYqCldPpVlVHuw/rD45vazw==", + "requires": { + "ethjs-abi": "0.2.0", + "ethjs-filter": "0.1.8", + "ethjs-util": "0.1.3", + "js-sha3": "0.5.5" + }, + "dependencies": { + "ethjs-abi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ethjs-abi/-/ethjs-abi-0.2.0.tgz", + "integrity": "sha1-0+LCIQEVIPxJm3FoIDbBT8wvWyU=", + "requires": { + "bn.js": "4.11.6", + "js-sha3": "0.5.5", + "number-to-bn": "1.7.0" + } + } + } + }, + "ethjs-filter": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/ethjs-filter/-/ethjs-filter-0.1.8.tgz", + "integrity": "sha512-qTDPskDL2UadHwjvM8A+WG9HwM4/FoSY3p3rMJORkHltYcAuiQZd2otzOYKcL5w2Q3sbAkW/E3yt/FPFL/AVXA==" + }, + "ethjs-query": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/ethjs-query/-/ethjs-query-0.3.7.tgz", + "integrity": "sha512-TZnKUwfkWjy0SowFdPLtmsytCorHi0i4vvkQn7Jg8rZt33cRzKhuzOwKr/G3vdigCc+ePXOhUGMcJSAPlOG44A==", + "requires": { + "ethjs-format": "0.2.7", + "ethjs-rpc": "0.2.0", + "promise-to-callback": "^1.0.0" + } + }, + "ethjs-rpc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ethjs-rpc/-/ethjs-rpc-0.2.0.tgz", + "integrity": "sha512-RINulkNZTKnj4R/cjYYtYMnFFaBcVALzbtEJEONrrka8IeoarNB9Jbzn+2rT00Cv8y/CxAI+GgY1d0/i2iQeOg==", + "requires": { + "promise-to-callback": "^1.0.0" + } + }, + "ethjs-util": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/ethjs-util/-/ethjs-util-0.1.3.tgz", + "integrity": "sha1-39XqSkANxeQhqInK9H4IGtp4u1U=", + "requires": { + "is-hex-prefixed": "1.0.0", + "strip-hex-prefix": "1.0.0" + } + }, + "js-sha3": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.5.5.tgz", + "integrity": "sha1-uvDA6MVK1ZA0R9+Wreekobynmko=" + } + } + }, "eth-phishing-detect": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/eth-phishing-detect/-/eth-phishing-detect-1.1.12.tgz", @@ -26341,6 +26443,15 @@ "deep-diff": "^0.3.5" } }, + "redux-mock-store": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.3.tgz", + "integrity": "sha512-ryhkkb/4D4CUGpAV2ln1GOY/uh51aczjcRz9k2L2bPx/Xja3c5pSGJJPyR25GNVRXtKIExScdAgFdiXp68GmJA==", + "dev": true, + "requires": { + "lodash.isplainobject": "^4.0.6" + } + }, "redux-test-utils": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/redux-test-utils/-/redux-test-utils-0.2.2.tgz", @@ -26742,6 +26853,11 @@ } } }, + "reselect": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", + "integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc=" + }, "resolve": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.4.0.tgz", diff --git a/package.json b/package.json index be1cd8d85..84db617bd 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "eth-hd-keyring": "^1.2.1", "eth-json-rpc-filters": "^1.2.6", "eth-json-rpc-infura": "^3.0.0", + "eth-method-registry": "^1.0.0", "eth-phishing-detect": "^1.1.4", "eth-query": "^2.1.2", "eth-sig-util": "^1.4.2", @@ -180,6 +181,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^2.2.0", "request-promise": "^4.2.1", + "reselect": "^3.0.1", "sandwich-expando": "^1.1.3", "semaphore": "^1.0.5", "semver": "^5.4.1", @@ -286,6 +288,7 @@ "react-addons-test-utils": "^15.5.1", "react-test-renderer": "^15.6.2", "react-testutils-additions": "^15.2.0", + "redux-mock-store": "^1.5.3", "redux-test-utils": "^0.2.2", "resolve-url-loader": "^2.3.0", "rimraf": "^2.6.2", diff --git a/test/e2e/beta/contract-test/contract.js b/test/e2e/beta/contract-test/contract.js index 18c866f21..8af008dce 100644 --- a/test/e2e/beta/contract-test/contract.js +++ b/test/e2e/beta/contract-test/contract.js @@ -32,35 +32,103 @@ var piggybankContract = web3.eth.contract([{'constant': false, 'inputs': [{'name const deployButton = document.getElementById('deployButton') const depositButton = document.getElementById('depositButton') const withdrawButton = document.getElementById('withdrawButton') +const sendButton = document.getElementById('sendButton') +const createToken = document.getElementById('createToken') +const transferTokens = document.getElementById('transferTokens') +const approveTokens = document.getElementById('approveTokens') deployButton.addEventListener('click', async function (event) { + var piggybank = await piggybankContract.new( + { + from: web3.eth.accounts[0], + data: '0x608060405234801561001057600080fd5b5033600160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000808190555061023b806100686000396000f300608060405260043610610057576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680632e1a7d4d1461005c5780638da5cb5b1461009d578063d0e30db0146100f4575b600080fd5b34801561006857600080fd5b5061008760048036038101908080359060200190929190505050610112565b6040518082815260200191505060405180910390f35b3480156100a957600080fd5b506100b26101d0565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b6100fc6101f6565b6040518082815260200191505060405180910390f35b6000600160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614151561017057600080fd5b8160008082825403925050819055503373ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f193505050501580156101c5573d6000803e3d6000fd5b506000549050919050565b600160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60003460008082825401925050819055506000549050905600a165627a7a72305820f237db3ec816a52589d82512117bc85bc08d3537683ffeff9059108caf3e5d400029', + gas: '4700000', + }, function (e, contract) { + console.log(e, contract) + if (typeof contract.address !== 'undefined') { + console.log('Contract mined! address: ' + contract.address + ' transactionHash: ' + contract.transactionHash) - var piggybank = await piggybankContract.new( - { - from: web3.eth.accounts[0], - data: '0x608060405234801561001057600080fd5b5033600160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506000808190555061023b806100686000396000f300608060405260043610610057576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680632e1a7d4d1461005c5780638da5cb5b1461009d578063d0e30db0146100f4575b600080fd5b34801561006857600080fd5b5061008760048036038101908080359060200190929190505050610112565b6040518082815260200191505060405180910390f35b3480156100a957600080fd5b506100b26101d0565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b6100fc6101f6565b6040518082815260200191505060405180910390f35b6000600160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614151561017057600080fd5b8160008082825403925050819055503373ffffffffffffffffffffffffffffffffffffffff166108fc839081150290604051600060405180830381858888f193505050501580156101c5573d6000803e3d6000fd5b506000549050919050565b600160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60003460008082825401925050819055506000549050905600a165627a7a72305820f237db3ec816a52589d82512117bc85bc08d3537683ffeff9059108caf3e5d400029', - gas: '4700000', - }, function (e, contract) { - console.log(e, contract) - if (typeof contract.address !== 'undefined') { - console.log('Contract mined! address: ' + contract.address + ' transactionHash: ' + contract.transactionHash) - - console.log(`contract`, contract) - - depositButton.addEventListener('click', function (event) { - contract.deposit({ from: web3.eth.accounts[0], value: '0x29a2241af62c0000' }, function (result) { - console.log(result) - }) + console.log(`contract`, contract) + + depositButton.addEventListener('click', function (event) { + contract.deposit({ from: web3.eth.accounts[0], value: '0x3782dace9d900000' }, function (result) { + console.log(result) + }) + }) + + withdrawButton.addEventListener('click', function (event) { + contract.withdraw('0xde0b6b3a7640000', { from: web3.eth.accounts[0] }, function (result) { + console.log(result) }) + }) + } + }) + + console.log(piggybank) +}) + +sendButton.addEventListener('click', function (event) { + web3.eth.sendTransaction({ + from: web3.eth.accounts[0], + to: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + value: '0x29a2241af62c0000', + gas: 21000, + gasPrice: 20000000000, + }, (result) => { + console.log(result) + }) +}) + + +createToken.addEventListener('click', async function (event) { + var _initialAmount = 100 + var _tokenName = 'TST' + var _decimalUnits = 0 + var _tokenSymbol = 'TST' + var humanstandardtokenContract = web3.eth.contract([{'constant': true, 'inputs': [], 'name': 'name', 'outputs': [{'name': '', 'type': 'string'}], 'payable': false, 'stateMutability': 'view', 'type': 'function'}, {'constant': false, 'inputs': [{'name': '_spender', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}], 'name': 'approve', 'outputs': [{'name': 'success', 'type': 'bool'}], 'payable': false, 'stateMutability': 'nonpayable', 'type': 'function'}, {'constant': true, 'inputs': [], 'name': 'totalSupply', 'outputs': [{'name': '', 'type': 'uint256'}], 'payable': false, 'stateMutability': 'view', 'type': 'function'}, {'constant': false, 'inputs': [{'name': '_from', 'type': 'address'}, {'name': '_to', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}], 'name': 'transferFrom', 'outputs': [{'name': 'success', 'type': 'bool'}], 'payable': false, 'stateMutability': 'nonpayable', 'type': 'function'}, {'constant': true, 'inputs': [], 'name': 'decimals', 'outputs': [{'name': '', 'type': 'uint8'}], 'payable': false, 'stateMutability': 'view', 'type': 'function'}, {'constant': true, 'inputs': [], 'name': 'version', 'outputs': [{'name': '', 'type': 'string'}], 'payable': false, 'stateMutability': 'view', 'type': 'function'}, {'constant': true, 'inputs': [{'name': '_owner', 'type': 'address'}], 'name': 'balanceOf', 'outputs': [{'name': 'balance', 'type': 'uint256'}], 'payable': false, 'stateMutability': 'view', 'type': 'function'}, {'constant': true, 'inputs': [], 'name': 'symbol', 'outputs': [{'name': '', 'type': 'string'}], 'payable': false, 'stateMutability': 'view', 'type': 'function'}, {'constant': false, 'inputs': [{'name': '_to', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}], 'name': 'transfer', 'outputs': [{'name': 'success', 'type': 'bool'}], 'payable': false, 'stateMutability': 'nonpayable', 'type': 'function'}, {'constant': false, 'inputs': [{'name': '_spender', 'type': 'address'}, {'name': '_value', 'type': 'uint256'}, {'name': '_extraData', 'type': 'bytes'}], 'name': 'approveAndCall', 'outputs': [{'name': 'success', 'type': 'bool'}], 'payable': false, 'stateMutability': 'nonpayable', 'type': 'function'}, {'constant': true, 'inputs': [{'name': '_owner', 'type': 'address'}, {'name': '_spender', 'type': 'address'}], 'name': 'allowance', 'outputs': [{'name': 'remaining', 'type': 'uint256'}], 'payable': false, 'stateMutability': 'view', 'type': 'function'}, {'inputs': [{'name': '_initialAmount', 'type': 'uint256'}, {'name': '_tokenName', 'type': 'string'}, {'name': '_decimalUnits', 'type': 'uint8'}, {'name': '_tokenSymbol', 'type': 'string'}], 'payable': false, 'stateMutability': 'nonpayable', 'type': 'constructor'}, {'payable': false, 'stateMutability': 'nonpayable', 'type': 'fallback'}, {'anonymous': false, 'inputs': [{'indexed': true, 'name': '_from', 'type': 'address'}, {'indexed': true, 'name': '_to', 'type': 'address'}, {'indexed': false, 'name': '_value', 'type': 'uint256'}], 'name': 'Transfer', 'type': 'event'}, {'anonymous': false, 'inputs': [{'indexed': true, 'name': '_owner', 'type': 'address'}, {'indexed': true, 'name': '_spender', 'type': 'address'}, {'indexed': false, 'name': '_value', 'type': 'uint256'}], 'name': 'Approval', 'type': 'event'}]) + return humanstandardtokenContract.new( + _initialAmount, + _tokenName, + _decimalUnits, + _tokenSymbol, + { + from: web3.eth.accounts[0], + data: '0x60806040526040805190810160405280600481526020017f48302e3100000000000000000000000000000000000000000000000000000000815250600690805190602001906200005192919062000143565b503480156200005f57600080fd5b50604051620011f3380380620011f383398101806040528101908080519060200190929190805182019291906020018051906020019092919080518201929190505050836000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508360028190555082600390805190602001906200010492919062000143565b5081600460006101000a81548160ff021916908360ff16021790555080600590805190602001906200013892919062000143565b5050505050620001f2565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282601f106200018657805160ff1916838001178555620001b7565b82800160010185558215620001b7579182015b82811115620001b657825182559160200191906001019062000199565b5b509050620001c69190620001ca565b5090565b620001ef91905b80821115620001eb576000816000905550600101620001d1565b5090565b90565b610ff180620002026000396000f3006080604052600436106100af576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806306fdde03146100c1578063095ea7b31461015157806318160ddd146101b657806323b872dd146101e1578063313ce5671461026657806354fd4d501461029757806370a082311461032757806395d89b411461037e578063a9059cbb1461040e578063cae9ca5114610473578063dd62ed3e1461051e575b3480156100bb57600080fd5b50600080fd5b3480156100cd57600080fd5b506100d6610595565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156101165780820151818401526020810190506100fb565b50505050905090810190601f1680156101435780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561015d57600080fd5b5061019c600480360381019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050610633565b604051808215151515815260200191505060405180910390f35b3480156101c257600080fd5b506101cb610725565b6040518082815260200191505060405180910390f35b3480156101ed57600080fd5b5061024c600480360381019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019092919050505061072b565b604051808215151515815260200191505060405180910390f35b34801561027257600080fd5b5061027b6109a4565b604051808260ff1660ff16815260200191505060405180910390f35b3480156102a357600080fd5b506102ac6109b7565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156102ec5780820151818401526020810190506102d1565b50505050905090810190601f1680156103195780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561033357600080fd5b50610368600480360381019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610a55565b6040518082815260200191505060405180910390f35b34801561038a57600080fd5b50610393610a9d565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156103d35780820151818401526020810190506103b8565b50505050905090810190601f1680156104005780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561041a57600080fd5b50610459600480360381019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050610b3b565b604051808215151515815260200191505060405180910390f35b34801561047f57600080fd5b50610504600480360381019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190803590602001908201803590602001908080601f0160208091040260200160405190810160405280939291908181526020018383808284378201915050505050509192919290505050610ca1565b604051808215151515815260200191505060405180910390f35b34801561052a57600080fd5b5061057f600480360381019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610f3e565b6040518082815260200191505060405180910390f35b60038054600181600116156101000203166002900480601f01602080910402602001604051908101604052809291908181526020018280546001816001161561010002031660029004801561062b5780601f106106005761010080835404028352916020019161062b565b820191906000526020600020905b81548152906001019060200180831161060e57829003601f168201915b505050505081565b600081600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925846040518082815260200191505060405180910390a36001905092915050565b60025481565b6000816000808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054101580156107f7575081600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410155b80156108035750600082115b1561099857816000808573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282540192505081905550816000808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000828254039250508190555081600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825403925050819055508273ffffffffffffffffffffffffffffffffffffffff168473ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef846040518082815260200191505060405180910390a36001905061099d565b600090505b9392505050565b600460009054906101000a900460ff1681565b60068054600181600116156101000203166002900480601f016020809104026020016040519081016040528092919081815260200182805460018160011615610100020316600290048015610a4d5780601f10610a2257610100808354040283529160200191610a4d565b820191906000526020600020905b815481529060010190602001808311610a3057829003601f168201915b505050505081565b60008060008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020549050919050565b60058054600181600116156101000203166002900480601f016020809104026020016040519081016040528092919081815260200182805460018160011615610100020316600290048015610b335780601f10610b0857610100808354040283529160200191610b33565b820191906000526020600020905b815481529060010190602001808311610b1657829003601f168201915b505050505081565b6000816000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410158015610b8b5750600082115b15610c9657816000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282540392505081905550816000808573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825401925050819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef846040518082815260200191505060405180910390a360019050610c9b565b600090505b92915050565b600082600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508373ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925856040518082815260200191505060405180910390a38373ffffffffffffffffffffffffffffffffffffffff1660405180807f72656365697665417070726f76616c28616464726573732c75696e743235362c81526020017f616464726573732c627974657329000000000000000000000000000000000000815250602e01905060405180910390207c01000000000000000000000000000000000000000000000000000000009004338530866040518563ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401808573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018481526020018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001828051906020019080838360005b83811015610ee2578082015181840152602081019050610ec7565b50505050905090810190601f168015610f0f5780820380516001836020036101000a031916815260200191505b509450505050506000604051808303816000875af1925050501515610f3357600080fd5b600190509392505050565b6000600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020549050929150505600a165627a7a723058200052919e0bc22b5adcd3d320be977df3a1dcc35d1a0160287383ba371900a1c50029', + gas: '4700000', + gasPrice: '20000000000', + }, function (e, contract) { + console.log(e, contract) + if (typeof contract.address !== 'undefined') { + console.log('Contract mined! address: ' + contract.address + ' transactionHash: ' + contract.transactionHash) + + document.getElementById('tokenAddress').innerHTML = contract.address - withdrawButton.addEventListener('click', function (event) { - contract.withdraw('0xde0b6b3a7640000', { from: web3.eth.accounts[0] }, function (result) { - console.log(result) - }) + transferTokens.addEventListener('click', function (event) { + console.log(`event`, event) + contract.transfer('0x2f318C334780961FB129D2a6c30D0763d9a5C970', '7', { + from: web3.eth.accounts[0], + to: contract.address, + data: '0xa9059cbb0000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C970000000000000000000000000000000000000000000000000000000000000000a', + gas: 60000, + gasPrice: '20000000000', + }, function (result) { + console.log('result', result) }) - } - }) + }) - console.log(piggybank) + approveTokens.addEventListener('click', function (event) { + contract.approve('0x2f318C334780961FB129D2a6c30D0763d9a5C970', '7', { + from: web3.eth.accounts[0], + to: contract.address, + data: '0x095ea7b30000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C9700000000000000000000000000000000000000000000000000000000000000005', + gas: 60000, + gasPrice: '20000000000', + }, function (result) { + console.log(result) + }) + }) + } + }) }) + diff --git a/test/e2e/beta/contract-test/index.html b/test/e2e/beta/contract-test/index.html index 0868633f7..0d63fd940 100644 --- a/test/e2e/beta/contract-test/index.html +++ b/test/e2e/beta/contract-test/index.html @@ -1,8 +1,33 @@ <html> +<head> + <title>E2E Test Dapp</title> +</head> <body> - <button id="deployButton">Deploy Contract</button> - <button id="depositButton">Deposit</button> - <button id="withdrawButton">Withdraw</button> -</body> + <div style="display: flex; flex-flow: column;"> + <div style="display: flex; font-size: 1.25rem;">Contract</div> + <div style="display: flex;"> + <button id="deployButton">Deploy Contract</button> + <button id="depositButton">Deposit</button> + <button id="withdrawButton">Withdraw</button> + </div> + </div> + <div style="display: flex; flex-flow: column;"> + <div style="display: flex; font-size: 1.25rem;">Send eth</div> + <div style="display: flex;"> + <button id="sendButton">Send</button> + </div> + </div> + <div style="display: flex; flex-flow: column;"> + <div style="display: flex; font-size: 1.25rem;">Send tokens</div> + <div id="tokenAddress"></div> + <div style="display: flex;"> + <button id="createToken">Create Token</button> + <button id="transferTokens">Transfer Tokens</button> + <button id="approveTokens">Approve Tokens</button> + </div> + </div> + <script src="contract.js"></script> +</body> + </html>
\ No newline at end of file diff --git a/test/e2e/beta/helpers.js b/test/e2e/beta/helpers.js index fcc3e96d6..828f87db7 100644 --- a/test/e2e/beta/helpers.js +++ b/test/e2e/beta/helpers.js @@ -1,16 +1,21 @@ const fs = require('fs') const mkdirp = require('mkdirp') const pify = require('pify') +const assert = require('assert') const {until} = require('selenium-webdriver') const { delay } = require('../func') module.exports = { + assertElementNotPresent, checkBrowserForConsoleErrors, - loadExtension, - verboseReportOnFailure, + closeAllWindowHandlesExcept, findElement, findElements, + loadExtension, openNewPage, + switchToWindowWithTitle, + verboseReportOnFailure, + waitUntilXWindowHandles, } async function loadExtension (driver, extensionId) { @@ -72,9 +77,57 @@ async function openNewPage (driver, url) { await delay(1000) const handles = await driver.getAllWindowHandles() - const secondHandle = handles[1] - await driver.switchTo().window(secondHandle) + const lastHandle = handles[handles.length - 1] + await driver.switchTo().window(lastHandle) await driver.get(url) await delay(1000) } + +async function waitUntilXWindowHandles (driver, x) { + const windowHandles = await driver.getAllWindowHandles() + if (windowHandles.length === x) return + await delay(1000) + return await waitUntilXWindowHandles(driver, x) +} + +async function switchToWindowWithTitle (driver, title, windowHandles) { + if (!windowHandles) { + windowHandles = await driver.getAllWindowHandles() + } else if (windowHandles.length === 0) { + throw new Error('No window with title: ' + title) + } + const firstHandle = windowHandles[0] + await driver.switchTo().window(firstHandle) + const handleTitle = await driver.getTitle() + + if (handleTitle === title) { + return firstHandle + } else { + return await switchToWindowWithTitle(driver, title, windowHandles.slice(1)) + } +} + +async function closeAllWindowHandlesExcept (driver, exceptions, windowHandles) { + exceptions = typeof exceptions === 'string' ? [ exceptions ] : exceptions + windowHandles = windowHandles || await driver.getAllWindowHandles() + const lastWindowHandle = windowHandles.pop() + if (!exceptions.includes(lastWindowHandle)) { + await driver.switchTo().window(lastWindowHandle) + await delay(1000) + await driver.close() + await delay(1000) + } + return windowHandles.length && await closeAllWindowHandlesExcept(driver, exceptions, windowHandles) +} + +async function assertElementNotPresent (webdriver, driver, by) { + try { + const dataTab = await findElement(driver, by, 4000) + if (dataTab) { + assert(false, 'Data tab should not be present') + } + } catch (err) { + assert(err instanceof webdriver.error.NoSuchElementError) + } +} diff --git a/test/e2e/beta/metamask-beta-ui.spec.js b/test/e2e/beta/metamask-beta-ui.spec.js index b07b1ecd7..caa49d450 100644 --- a/test/e2e/beta/metamask-beta-ui.spec.js +++ b/test/e2e/beta/metamask-beta-ui.spec.js @@ -11,12 +11,16 @@ const { getExtensionIdFirefox, } = require('../func') const { + assertElementNotPresent, + checkBrowserForConsoleErrors, + closeAllWindowHandlesExcept, findElement, findElements, - checkBrowserForConsoleErrors, loadExtension, - verboseReportOnFailure, openNewPage, + switchToWindowWithTitle, + verboseReportOnFailure, + waitUntilXWindowHandles, } = require('./helpers') describe('MetaMask', function () { @@ -25,7 +29,7 @@ describe('MetaMask', function () { let tokenAddress const testSeedPhrase = 'phrase upgrade clock rough situate wedding elder clever doctor stamp excess tent' - const tinyDelayMs = 1000 + const tinyDelayMs = 200 const regularDelayMs = tinyDelayMs * 2 const largeDelayMs = regularDelayMs * 2 @@ -80,20 +84,29 @@ describe('MetaMask', function () { networkSelector = await findElement(driver, By.css('#network_component')) } catch (e) { await loadExtension(driver, extensionId) + await delay(largeDelayMs * 2) + networkSelector = await findElement(driver, By.css('#network_component')) } await delay(regularDelayMs) }) - it('use the local network', async function () { + it('uses the local network', async function () { await networkSelector.click() await delay(regularDelayMs) - const localhost = await findElement(driver, By.xpath(`//li[contains(text(), 'Localhost')]`)) + const networks = await findElements(driver, By.css('.dropdown-menu-item')) + const localhost = networks[4] + await driver.wait(until.elementTextMatches(localhost, /Localhost/)) await localhost.click() await delay(regularDelayMs) }) it('selects the new UI option', async () => { + try { + const overlay = await findElement(driver, By.css('.full-flex-height')) + await driver.wait(until.stalenessOf(overlay)) + } catch (e) {} + const button = await findElement(driver, By.xpath("//p[contains(text(), 'Try Beta Version')]")) await button.click() await delay(regularDelayMs) @@ -188,8 +201,20 @@ describe('MetaMask', function () { await delay(regularDelayMs) }) - async function retypeSeedPhrase (words) { + async function retypeSeedPhrase (words, wasReloaded) { try { + if (wasReloaded) { + const byRevealButton = By.css('.backup-phrase__secret-blocker .backup-phrase__reveal-button') + await driver.wait(until.elementLocated(byRevealButton, 10000)) + const revealSeedPhraseButton = await findElement(driver, byRevealButton, 10000) + await revealSeedPhraseButton.click() + await delay(regularDelayMs) + + const nextScreen = await findElement(driver, By.css('.backup-phrase button')) + await nextScreen.click() + await delay(regularDelayMs) + } + const word0 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[0]}')]`), 10000) await word0.click() @@ -250,7 +275,7 @@ describe('MetaMask', function () { await delay(tinyDelayMs) } catch (e) { await loadExtension(driver, extensionId) - await retypeSeedPhrase(words) + await retypeSeedPhrase(words, true) } } @@ -303,7 +328,7 @@ describe('MetaMask', function () { it('accepts the account password after lock', async () => { await driver.findElement(By.id('password')).sendKeys('correct horse battery staple') await driver.findElement(By.id('password')).sendKeys(Key.ENTER) - await delay(regularDelayMs * 4) + await delay(largeDelayMs * 4) }) }) @@ -324,10 +349,10 @@ describe('MetaMask', function () { const create = await findElement(driver, By.xpath(`//button[contains(text(), 'Create')]`)) await create.click() - await delay(regularDelayMs) + await delay(largeDelayMs) }) - it('should correct account name', async () => { + it('should display correct account name', async () => { const accountName = await findElement(driver, By.css('.account-name')) assert.equal(await accountName.getText(), '2nd account') await delay(regularDelayMs) @@ -366,8 +391,7 @@ describe('MetaMask', function () { it('balance renders', async () => { const balance = await findElement(driver, By.css('.balance-display .token-amount')) - const tokenAmount = await balance.getText() - assert.equal(tokenAmount, '100.000 ETH') + await driver.wait(until.elementTextMatches(balance, /100.+ETH/)) await delay(regularDelayMs) }) }) @@ -383,6 +407,9 @@ describe('MetaMask', function () { await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970') await inputAmount.sendKeys('1') + const inputValue = await inputAmount.getAttribute('value') + assert.equal(inputValue, '1') + // Set the gas limit const configureGas = await findElement(driver, By.css('.send-v2__gas-fee-display button')) await configureGas.click() @@ -404,59 +431,71 @@ describe('MetaMask', function () { it('confirms the transaction', async function () { const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirmButton.click() - await delay(regularDelayMs) + await delay(largeDelayMs) }) it('finds the transaction in the transactions list', async function () { const transactions = await findElements(driver, By.css('.tx-list-item')) assert.equal(transactions.length, 1) - const txValues = await findElement(driver, By.css('.tx-list-value')) - await driver.wait(until.elementTextMatches(txValues, /1\sETH/), 10000) + if (process.env.SELENIUM_BROWSER !== 'firefox') { + const txValues = await findElement(driver, By.css('.tx-list-value')) + await driver.wait(until.elementTextMatches(txValues, /1\sETH/), 10000) + } }) }) - describe('Send ETH from Faucet', () => { - it('starts a send transaction inside Faucet', async () => { - await openNewPage(driver, 'https://faucet.metamask.io') + describe('Send ETH from dapp', () => { + it('starts a send transaction inside the dapp', async () => { + await openNewPage(driver, 'http://127.0.0.1:8080/') + await delay(regularDelayMs) - const [extension, faucet] = await driver.getAllWindowHandles() - await driver.switchTo().window(faucet) + await waitUntilXWindowHandles(driver, 2) + let windowHandles = await driver.getAllWindowHandles() + const extension = windowHandles[0] + const dapp = windowHandles[1] - const faucetPageTitle = await findElement(driver, By.css('.container-fluid')) - await driver.wait(until.elementTextMatches(faucetPageTitle, /MetaMask/)) + await driver.switchTo().window(dapp) await delay(regularDelayMs) - const send1eth = await findElement(driver, By.xpath(`//button[contains(text(), '10 ether')]`), 14000) - await send1eth.click() + const send3eth = await findElement(driver, By.xpath(`//button[contains(text(), 'Send')]`), 10000) + await send3eth.click() await delay(regularDelayMs) - await driver.switchTo().window(extension) - await loadExtension(driver, extensionId) + windowHandles = await driver.getAllWindowHandles() + await driver.switchTo().window(windowHandles[2]) await delay(regularDelayMs) - const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`), 14000) + assertElementNotPresent(webdriver, driver, By.xpath(`//li[contains(text(), 'Data')]`)) + + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`), 10000) await confirmButton.click() await delay(regularDelayMs) - await driver.switchTo().window(faucet) - await delay(regularDelayMs) - await driver.close() - await delay(regularDelayMs) + await closeAllWindowHandlesExcept(driver, [extension, dapp]) await driver.switchTo().window(extension) await delay(regularDelayMs) - await loadExtension(driver, extensionId) - await delay(regularDelayMs) + }) + + it('finds the transaction in the transactions list', async function () { + const transactions = await findElements(driver, By.css('.tx-list-item')) + assert.equal(transactions.length, 2) + + const txValues = await findElement(driver, By.css('.tx-list-value')) + await driver.wait(until.elementTextMatches(txValues, /3\sETH/), 10000) }) }) describe('Deploy contract and call contract methods', () => { let extension - let contractTestPage - it('confirms a deploy contract transaction', async () => { - await openNewPage(driver, 'http://127.0.0.1:8080/'); + let dapp + it('creates a deploy contract transaction', async () => { + const windowHandles = await driver.getAllWindowHandles() + extension = windowHandles[0] + dapp = windowHandles[1] + await delay(tinyDelayMs) - [extension, contractTestPage] = await driver.getAllWindowHandles() + await driver.switchTo().window(dapp) await delay(regularDelayMs) const deployContractButton = await findElement(driver, By.css('#deployButton')) @@ -466,10 +505,28 @@ describe('MetaMask', function () { await driver.switchTo().window(extension) await delay(regularDelayMs) - const txListItem = await findElement(driver, By.css('.tx-list-item')) + const txListItem = await findElement(driver, By.xpath(`//span[contains(text(), 'Contract Deployment')]`)) await txListItem.click() await delay(regularDelayMs) + }) + + it('displays the contract creation data', async () => { + const dataTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Data')]`)) + dataTab.click() + await (regularDelayMs) + + await findElement(driver, By.xpath(`//div[contains(text(), '127.0.0.1')]`)) + + const confirmDataDiv = await findElement(driver, By.css('.confirm-page-container-content__data-box')) + const confirmDataText = await confirmDataDiv.getText() + assert.equal(confirmDataText.match(/0x608060405234801561001057600080fd5b5033600160006101000a81548173ffffffffffffffffffffffffffffffffffffffff/)) + const detailsTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Details')]`)) + detailsTab.click() + await (regularDelayMs) + }) + + it('confirms a deploy contract transaction', async () => { const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirmButton.click() await delay(regularDelayMs) @@ -479,10 +536,11 @@ describe('MetaMask', function () { const txAccounts = await findElements(driver, By.css('.tx-list-account')) assert.equal(await txAccounts[0].getText(), 'Contract Deployment') + await delay(regularDelayMs) }) it('calls and confirms a contract method where ETH is sent', async () => { - await driver.switchTo().window(contractTestPage) + await driver.switchTo().window(dapp) await delay(regularDelayMs) const depositButton = await findElement(driver, By.css('#depositButton')) @@ -492,17 +550,19 @@ describe('MetaMask', function () { await driver.switchTo().window(extension) await delay(regularDelayMs) - const txListItem = await findElement(driver, By.css('.tx-list-item')) - await txListItem.click() + await findElements(driver, By.css('.tx-list-pending-item-container')) + const [txListValue] = await findElements(driver, By.css('.tx-list-value')) + await driver.wait(until.elementTextMatches(txListValue, /4\sETH/), 10000) + await txListValue.click() await delay(regularDelayMs) // Set the gas limit - const configureGas = await findElement(driver, By.css('.sliders-icon-container')) + const configureGas = await findElement(driver, By.css('.confirm-detail-row__header-text--edit')) await configureGas.click() await delay(regularDelayMs) const gasModal = await driver.findElement(By.css('span .modal')) - await driver.wait(until.elementLocated(By.css('.send-v2__customize-gas__title'))) + await driver.wait(until.elementLocated(By.css('.customize-gas__title'))) const [gasPriceInput, gasLimitInput] = await findElements(driver, By.css('.customize-gas-input')) await gasPriceInput.clear() @@ -524,7 +584,7 @@ describe('MetaMask', function () { await driver.wait(until.elementTextMatches(txStatuses[0], /Confirmed/)) const txValues = await findElement(driver, By.css('.tx-list-value')) - await driver.wait(until.elementTextMatches(txValues, /3\sETH/), 10000) + await driver.wait(until.elementTextMatches(txValues, /4\sETH/), 10000) const txAccounts = await findElements(driver, By.css('.tx-list-account')) const firstTxAddress = await txAccounts[0].getText() @@ -532,7 +592,7 @@ describe('MetaMask', function () { }) it('calls and confirms a contract method where ETH is received', async () => { - await driver.switchTo().window(contractTestPage) + await driver.switchTo().window(dapp) await delay(regularDelayMs) const withdrawButton = await findElement(driver, By.css('#withdrawButton')) @@ -556,38 +616,31 @@ describe('MetaMask', function () { const txValues = await findElement(driver, By.css('.tx-list-value')) await driver.wait(until.elementTextMatches(txValues, /0\sETH/), 10000) - await driver.switchTo().window(contractTestPage) - await driver.close() + await closeAllWindowHandlesExcept(driver, [extension, dapp]) await driver.switchTo().window(extension) }) it('renders the correct ETH balance', async () => { const balance = await findElement(driver, By.css('.tx-view .balance-display .token-amount')) - await driver.wait(until.elementTextMatches(balance, /^86.*ETH.*$/), 10000) - const tokenAmount = await balance.getText() - assert.ok(/^86.*ETH.*$/.test(tokenAmount)) await delay(regularDelayMs) + if (process.env.SELENIUM_BROWSER !== 'firefox') { + await driver.wait(until.elementTextMatches(balance, /^92.*ETH.*$/), 10000) + const tokenAmount = await balance.getText() + assert.ok(/^92.*ETH.*$/.test(tokenAmount)) + await delay(regularDelayMs) + } }) }) - describe('Add a custom token from TokenFactory', () => { + describe('Add a custom token from a dapp', () => { it('creates a new token', async () => { - openNewPage(driver, 'https://tokenfactory.surge.sh/#/factory') - - await delay(regularDelayMs * 10) - const [extension, tokenFactory] = await driver.getAllWindowHandles() + const windowHandles = await driver.getAllWindowHandles() + const extension = windowHandles[0] + const dapp = windowHandles[1] + await delay(regularDelayMs * 2) - const [ - totalSupply, - tokenName, - tokenDecimal, - tokenSymbol, - ] = await findElements(driver, By.css('.form-control')) - - await totalSupply.sendKeys('100') - await tokenName.sendKeys('Test') - await tokenDecimal.sendKeys('0') - await tokenSymbol.sendKeys('TST') + await driver.switchTo().window(dapp) + await delay(regularDelayMs) const createToken = await findElement(driver, By.xpath(`//button[contains(text(), 'Create Token')]`)) await createToken.click() @@ -601,15 +654,16 @@ describe('MetaMask', function () { await confirmButton.click() await delay(regularDelayMs) - await driver.switchTo().window(tokenFactory) - await delay(regularDelayMs) + await driver.switchTo().window(dapp) + await delay(tinyDelayMs) - const tokenContactAddress = await driver.findElement(By.css('div > div > div:nth-child(2) > span:nth-child(3)')) - tokenAddress = await tokenContactAddress.getText() + const tokenContractAddress = await driver.findElement(By.css('#tokenAddress')) + await driver.wait(until.elementTextMatches(tokenContractAddress, /0x/)) + tokenAddress = await tokenContractAddress.getText() - await driver.close() - await driver.switchTo().window(extension) - await loadExtension(driver, extensionId) + await delay(regularDelayMs) + await closeAllWindowHandlesExcept(driver, [extension, dapp]) + await delay(regularDelayMs) await driver.switchTo().window(extension) await delay(regularDelayMs) @@ -668,7 +722,7 @@ describe('MetaMask', function () { gasModal = await driver.findElement(By.css('span .modal')) }) - it('customizes gas', async () => { + it('opens customizes gas modal', async () => { await driver.wait(until.elementLocated(By.css('.send-v2__customize-gas__title'))) const save = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`)) await save.click() @@ -684,6 +738,24 @@ describe('MetaMask', function () { await delay(regularDelayMs) }) + it('displays the token transfer data', async () => { + const dataTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Data')]`)) + dataTab.click() + await (regularDelayMs) + + const functionType = await findElement(driver, By.css('.confirm-page-container-content__function-type')) + const functionTypeText = await functionType.getText() + assert.equal(functionTypeText, 'Transfer') + + const confirmDataDiv = await findElement(driver, By.css('.confirm-page-container-content__data-box')) + const confirmDataText = await confirmDataDiv.getText() + assert.equal(confirmDataText.match(/0xa9059cbb0000000000000000000000002f318c334780961fb129d2a6c30d0763d9a5c97/)) + + const detailsTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Details')]`)) + detailsTab.click() + await (regularDelayMs) + }) + it('submits the transaction', async function () { const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirmButton.click() @@ -709,37 +781,33 @@ describe('MetaMask', function () { }) }) - describe('Send a custom token from TokenFactory', () => { + describe('Send a custom token from dapp', () => { let gasModal it('sends an already created token', async () => { - openNewPage(driver, `https://tokenfactory.surge.sh/#/token/${tokenAddress}`) - - const [extension] = await driver.getAllWindowHandles() - - const [ - transferToAddress, - transferToAmount, - ] = await findElements(driver, By.css('.form-control')) + const windowHandles = await driver.getAllWindowHandles() + const extension = windowHandles[0] + const dapp = await switchToWindowWithTitle(driver, 'E2E Test Dapp', windowHandles) + await closeAllWindowHandlesExcept(driver, [extension, dapp]) + await delay(regularDelayMs) - await transferToAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970') - await transferToAmount.sendKeys('26') + await driver.switchTo().window(dapp) + await delay(tinyDelayMs) - const transferAmountButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Transfer Amount')]`)) - await transferAmountButton.click() - await delay(regularDelayMs) + const transferTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Transfer Tokens')]`)) + await transferTokens.click() - const [,, popup] = await driver.getAllWindowHandles() - await driver.switchTo().window(popup) - await driver.close() + await closeAllWindowHandlesExcept(driver, [extension, dapp]) await driver.switchTo().window(extension) - await delay(regularDelayMs) + await delay(largeDelayMs) - const [txListItem] = await findElements(driver, By.css('.tx-list-item')) - await txListItem.click() + await findElements(driver, By.css('.tx-list-pending-item-container')) + const [txListValue] = await findElements(driver, By.css('.tx-list-value')) + await driver.wait(until.elementTextMatches(txListValue, /7\sTST/), 10000) + await txListValue.click() await delay(regularDelayMs) // Set the gas limit - const configureGas = await driver.wait(until.elementLocated(By.css('.send-v2__gas-fee-display button'))) + const configureGas = await driver.wait(until.elementLocated(By.css('.confirm-detail-row__header-text--edit')), 10000) await configureGas.click() await delay(regularDelayMs) @@ -747,7 +815,7 @@ describe('MetaMask', function () { }) it('customizes gas', async () => { - await driver.wait(until.elementLocated(By.css('.send-v2__customize-gas__title'))) + await driver.wait(until.elementLocated(By.css('.customize-gas__title'))) const [gasPriceInput, gasLimitInput] = await findElements(driver, By.css('.customize-gas-input')) await gasPriceInput.clear() @@ -766,12 +834,12 @@ describe('MetaMask', function () { await gasLimitInput.sendKeys(Key.BACK_SPACE) } - const save = await findElement(driver, By.css('.send-v2__customize-gas__save')) + const save = await findElement(driver, By.css('.customize-gas__save')) await save.click() await driver.wait(until.stalenessOf(gasModal)) - const gasFeeInput = await findElement(driver, By.css('.currency-display__input')) - assert.equal(await gasFeeInput.getAttribute('value'), 0.0006) + const gasFeeInputs = await findElements(driver, By.css('.confirm-detail-row__eth')) + assert.equal(await gasFeeInputs[0].getText(), '♦ 0.0006') }) it('submits the transaction', async function () { @@ -785,7 +853,7 @@ describe('MetaMask', function () { assert.equal(transactions.length, 2) const txValues = await findElements(driver, By.css('.tx-list-value')) - await driver.wait(until.elementTextMatches(txValues[0], /26\sTST/)) + await driver.wait(until.elementTextMatches(txValues[0], /7\sTST/)) const txStatuses = await findElements(driver, By.css('.tx-list-status')) await driver.wait(until.elementTextMatches(txStatuses[0], /Confirmed/)) @@ -799,11 +867,110 @@ describe('MetaMask', function () { // or possibly until we use latest version of firefox in the tests if (process.env.SELENIUM_BROWSER !== 'firefox') { const tokenBalanceAmount = await findElement(driver, By.css('.token-balance__amount')) - assert.equal(await tokenBalanceAmount.getText(), '24') + assert.equal(await tokenBalanceAmount.getText(), '43') } }) }) + describe('Approves a custom token from dapp', () => { + let gasModal + it('approves an already created token', async () => { + const windowHandles = await driver.getAllWindowHandles() + const extension = windowHandles[0] + const dapp = await switchToWindowWithTitle(driver, 'E2E Test Dapp', windowHandles) + await closeAllWindowHandlesExcept(driver, [extension, dapp]) + await delay(regularDelayMs) + + await driver.switchTo().window(dapp) + await delay(tinyDelayMs) + + const transferTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Approve Tokens')]`)) + await transferTokens.click() + + await closeAllWindowHandlesExcept(driver, extension) + await driver.switchTo().window(extension) + await delay(regularDelayMs) + + const [txListItem] = await findElements(driver, By.css('.tx-list-item')) + const [txListValue] = await findElements(driver, By.css('.tx-list-value')) + await driver.wait(until.elementTextMatches(txListValue, /0\sETH/)) + await txListItem.click() + await delay(regularDelayMs) + }) + + it('displays the token approval data', async () => { + const dataTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Data')]`)) + dataTab.click() + await (regularDelayMs) + + const functionType = await findElement(driver, By.css('.confirm-page-container-content__function-type')) + const functionTypeText = await functionType.getText() + assert.equal(functionTypeText, 'Approve') + + const confirmDataDiv = await findElement(driver, By.css('.confirm-page-container-content__data-box')) + const confirmDataText = await confirmDataDiv.getText() + assert.equal(confirmDataText.match(/0x095ea7b30000000000000000000000002f318c334780961fb129d2a6c30d0763d9a5c97/)) + + const detailsTab = await findElement(driver, By.xpath(`//li[contains(text(), 'Details')]`)) + detailsTab.click() + await (regularDelayMs) + + const approvalWarning = await findElement(driver, By.css('.confirm-page-container-warning__warning')) + const approvalWarningText = await approvalWarning.getText() + assert(approvalWarningText.match(/By approving this/)) + await (regularDelayMs) + }) + + it('opens the gas edit modal', async () => { + const configureGas = await driver.wait(until.elementLocated(By.css('.confirm-detail-row__header-text--edit'))) + await configureGas.click() + await delay(regularDelayMs) + + gasModal = await driver.findElement(By.css('span .modal')) + }) + + it('customizes gas', async () => { + await driver.wait(until.elementLocated(By.css('.customize-gas__title'))) + + const [gasPriceInput, gasLimitInput] = await findElements(driver, By.css('.customize-gas-input')) + await gasPriceInput.clear() + await delay(tinyDelayMs) + await gasPriceInput.sendKeys('10') + await delay(tinyDelayMs) + await gasLimitInput.clear() + await delay(tinyDelayMs) + await gasLimitInput.sendKeys(Key.chord(Key.CONTROL, 'a')) + await gasLimitInput.sendKeys('60000') + await gasLimitInput.sendKeys(Key.chord(Key.CONTROL, 'e')) + + // Needed for different behaviour of input in different versions of firefox + const gasLimitInputValue = await gasLimitInput.getAttribute('value') + if (gasLimitInputValue === '600001') { + await gasLimitInput.sendKeys(Key.BACK_SPACE) + } + + const save = await findElement(driver, By.css('.customize-gas__save')) + await save.click() + await driver.wait(until.stalenessOf(gasModal)) + + const gasFeeInputs = await findElements(driver, By.css('.confirm-detail-row__eth')) + assert.equal(await gasFeeInputs[0].getText(), '♦ 0.0006') + }) + + it('submits the transaction', async function () { + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) + await confirmButton.click() + await delay(regularDelayMs) + }) + + it('finds the transaction in the transactions list', async function () { + const txValues = await findElements(driver, By.css('.tx-list-value')) + await driver.wait(until.elementTextMatches(txValues[0], /0\sETH/)) + const txStatuses = await findElements(driver, By.css('.tx-list-status')) + await driver.wait(until.elementTextMatches(txStatuses[0], /Confirmed/)) + }) + }) + describe('Hide token', () => { it('hides the token when clicked', async () => { const [hideTokenEllipsis] = await findElements(driver, By.css('.token-list-item__ellipsis')) diff --git a/test/e2e/metamask.spec.js b/test/e2e/metamask.spec.js index a32b924b8..b6efae5b3 100644 --- a/test/e2e/metamask.spec.js +++ b/test/e2e/metamask.spec.js @@ -234,7 +234,7 @@ describe('Metamask popup page', function () { submitButton.click() - await delay(500) + await delay(1500) }) it('finds the transaction in the transactions list', async function () { diff --git a/test/integration/lib/confirm-sig-requests.js b/test/integration/lib/confirm-sig-requests.js index 5613c0dcb..dcc25c493 100644 --- a/test/integration/lib/confirm-sig-requests.js +++ b/test/integration/lib/confirm-sig-requests.js @@ -1,5 +1,6 @@ const reactTriggerChange = require('react-trigger-change') const { + timeout, queryAsync, } = require('../../lib/util') @@ -18,14 +19,14 @@ async function runConfirmSigRequestsTest (assert, done) { selectState.val('confirm sig requests') reactTriggerChange(selectState[0]) - // await timeout(1000000) - const pendingRequestItem = $.find('.tx-list-item.tx-list-pending-item-container.tx-list-clickable') if (pendingRequestItem[0]) { pendingRequestItem[0].click() } + await timeout(1000) + let confirmSigHeadline = await queryAsync($, '.request-signature__headline') assert.equal(confirmSigHeadline[0].textContent, 'Your signature is being requested') @@ -37,7 +38,7 @@ async function runConfirmSigRequestsTest (assert, done) { let confirmSigSignButton = await queryAsync($, 'button.btn-primary.btn--large') confirmSigSignButton[0].click() - + await timeout(1000) confirmSigHeadline = await queryAsync($, '.request-signature__headline') assert.equal(confirmSigHeadline[0].textContent, 'Your signature is being requested') @@ -46,7 +47,7 @@ async function runConfirmSigRequestsTest (assert, done) { confirmSigSignButton = await queryAsync($, 'button.btn-primary.btn--large') confirmSigSignButton[0].click() - + await timeout(1000) confirmSigHeadline = await queryAsync($, '.request-signature__headline') assert.equal(confirmSigHeadline[0].textContent, 'Your signature is being requested') @@ -57,6 +58,5 @@ async function runConfirmSigRequestsTest (assert, done) { confirmSigSignButton = await queryAsync($, 'button.btn-primary.btn--large') confirmSigSignButton[0].click() - const txView = await queryAsync($, '.tx-view') - assert.ok(txView[0], 'Should return to the account details screen after confirming') + await timeout(2000) } diff --git a/test/integration/lib/currency-localization.js b/test/integration/lib/currency-localization.js index 3ad1a23e5..d42b7495d 100644 --- a/test/integration/lib/currency-localization.js +++ b/test/integration/lib/currency-localization.js @@ -19,6 +19,7 @@ async function runCurrencyLocalizationTest (assert, done) { console.log('*** start runCurrencyLocalizationTest') const selectState = await queryAsync($, 'select') selectState.val('currency localization') + await timeout(1000) reactTriggerChange(selectState[0]) await timeout(1000) const txView = await queryAsync($, '.tx-view') diff --git a/test/integration/lib/send-new-ui.js b/test/integration/lib/send-new-ui.js index d5e80151c..406863ca6 100644 --- a/test/integration/lib/send-new-ui.js +++ b/test/integration/lib/send-new-ui.js @@ -125,18 +125,18 @@ async function runSendFlowTest (assert, done) { reactTriggerChange(selectState[0]) const confirmFromName = (await queryAsync($, '.sender-to-recipient__sender-name')).first() - assert.equal(confirmFromName[0].textContent, 'Send Account 2', 'confirm screen should show correct from name') + assert.equal(confirmFromName[0].textContent, 'Send Account 4', 'confirm screen should show correct from name') const confirmToName = (await queryAsync($, '.sender-to-recipient__recipient-name')).last() assert.equal(confirmToName[0].textContent, 'Send Account 3', 'confirm screen should show correct to name') - const confirmScreenRows = await queryAsync($, '.confirm-screen-rows') - const confirmScreenGas = confirmScreenRows.find('.currency-display__converted-value')[0] - assert.equal(confirmScreenGas.textContent, '$3.60 USD', 'confirm screen should show correct gas') - const confirmScreenTotal = confirmScreenRows.find('.confirm-screen-row-info')[2] - assert.equal(confirmScreenTotal.textContent, '$2,405.36 USD', 'confirm screen should show correct total') + const confirmScreenRowFiats = await queryAsync($, '.confirm-detail-row__fiat') + const confirmScreenGas = confirmScreenRowFiats[0] + assert.equal(confirmScreenGas.textContent, '$3.60', 'confirm screen should show correct gas') + const confirmScreenTotal = confirmScreenRowFiats[1] + assert.equal(confirmScreenTotal.textContent, '$2,405.36', 'confirm screen should show correct total') - const confirmScreenBackButton = await queryAsync($, '.page-container__back-button') + const confirmScreenBackButton = await queryAsync($, '.confirm-page-container-header__back-button') confirmScreenBackButton[0].click() const sendFromFieldItemInEdit = await queryAsync($, '.account-list-item') diff --git a/test/unit/actions/tx_test.js b/test/unit/actions/tx_test.js index c110f71fc..160cd4552 100644 --- a/test/unit/actions/tx_test.js +++ b/test/unit/actions/tx_test.js @@ -1,74 +1,54 @@ -// var jsdom = require('mocha-jsdom') var assert = require('assert') -var freeze = require('deep-freeze-strict') var path = require('path') -var sinon = require('sinon') -var actions = require(path.join(__dirname, '..', '..', '..', 'ui', 'app', 'actions.js')) -var reducers = require(path.join(__dirname, '..', '..', '..', 'ui', 'app', 'reducers.js')) +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' -describe('tx confirmation screen', function () { - beforeEach(function () { - this.sinon = sinon.createSandbox() - }) - - afterEach(function () { - this.sinon.restore() - }) +const actions = require(path.join(__dirname, '../../../ui/app/actions.js')) - var initialState, result +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) - describe('when there is only one tx', function () { - var firstTxId = 1457634084250832 - - beforeEach(function () { - initialState = { - appState: { - currentView: { - name: 'confTx', - }, - }, - metamask: { - unapprovedTxs: { - '1457634084250832': { - id: 1457634084250832, - status: 'unconfirmed', - time: 1457634084250, - }, - }, +describe('tx confirmation screen', function () { + const txId = 1457634084250832 + const initialState = { + appState: { + currentView: { + name: 'confTx', + }, + }, + metamask: { + unapprovedTxs: { + [txId]: { + id: txId, + status: 'unconfirmed', + time: 1457634084250, }, - } - freeze(initialState) + }, + }, + } + + const store = mockStore(initialState) + + describe('cancelTx', function () { + before(function (done) { + actions._setBackgroundConnection({ + approveTransaction (txId, cb) { cb('An error!') }, + cancelTransaction (txId, cb) { cb() }, + clearSeedWordCache (cb) { cb() }, + getState (cb) { cb() }, + }) + done() }) - describe('cancelTx', function () { - before(function (done) { - actions._setBackgroundConnection({ - approveTransaction (txId, cb) { cb('An error!') }, - cancelTransaction (txId, cb) { cb() }, - clearSeedWordCache (cb) { cb() }, + it('creates COMPLETED_TX with the cancelled transaction ID', function (done) { + store.dispatch(actions.cancelTx({ id: txId })) + .then(() => { + const storeActions = store.getActions() + const completedTxAction = storeActions.find(({ type }) => type === actions.COMPLETED_TX) + assert.equal(completedTxAction.value, txId) + done() }) - - actions.cancelTx({value: firstTxId})((action) => { - result = reducers(initialState, action) - }) - done() - }) - - it('should transition to the account detail view', function () { - assert.equal(result.appState.currentView.name, 'accountDetail') - }) - - it('should have no unconfirmed txs remaining', function () { - var count = getUnconfirmedTxCount(result) - assert.equal(count, 0) - }) }) }) }) - -function getUnconfirmedTxCount (state) { - var txs = state.metamask.unapprovedTxs - var count = Object.keys(txs).length - return count -} diff --git a/test/unit/app/controllers/transactions/tx-controller-test.js b/test/unit/app/controllers/transactions/tx-controller-test.js index d4c5ff0ec..26dc7b656 100644 --- a/test/unit/app/controllers/transactions/tx-controller-test.js +++ b/test/unit/app/controllers/transactions/tx-controller-test.js @@ -353,9 +353,16 @@ describe('Transaction Controller', function () { ]) }) - it('should set the transaction to rejected from unapproved', async function () { - await txController.cancelTransaction(0) - assert.equal(txController.txStateManager.getTx(0).status, 'rejected') + it('should emit a status change to rejected', function (done) { + txController.once('tx:status-update', (txId, status) => { + try { + assert.equal(status, 'rejected', 'status should e rejected') + assert.equal(txId, 0, 'id should e 0') + done() + } catch (e) { done(e) } + }) + + txController.cancelTransaction(0) }) }) diff --git a/test/unit/app/controllers/transactions/tx-state-manager-test.js b/test/unit/app/controllers/transactions/tx-state-manager-test.js index 2f91b1545..88bdaa60e 100644 --- a/test/unit/app/controllers/transactions/tx-state-manager-test.js +++ b/test/unit/app/controllers/transactions/tx-state-manager-test.js @@ -43,14 +43,13 @@ describe('TransactionStateManager', function () { }) describe('#setTxStatusRejected', function () { - it('sets the tx status to rejected', function () { + it('sets the tx status to rejected and removes it from history', function () { const tx = { id: 1, status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} } txStateManager.addTx(tx) txStateManager.setTxStatusRejected(1) const result = txStateManager.getTxList() assert.ok(Array.isArray(result)) - assert.equal(result.length, 1) - assert.equal(result[0].status, 'rejected') + assert.equal(result.length, 0) }) it('should emit a rejected event to signal the exciton of callback', (done) => { @@ -287,4 +286,18 @@ describe('TransactionStateManager', function () { }) }) + + describe('#_removeTx', function () { + it('should remove the transaction from the storage', () => { + txStateManager._saveTxList([ {id: 1} ]) + txStateManager._removeTx(1) + assert(!txStateManager.getFullTxList().length, 'txList should be empty') + }) + + it('should only remove the transaction with ID 1 from the storage', () => { + txStateManager._saveTxList([ {id: 1}, {id: 2} ]) + txStateManager._removeTx(1) + assert.equal(txStateManager.getFullTxList()[0].id, 2, 'txList should have a id of 2') + }) + }) }) diff --git a/test/unit/migrations/027-test.js b/test/unit/migrations/027-test.js new file mode 100644 index 000000000..3ec9f0c0e --- /dev/null +++ b/test/unit/migrations/027-test.js @@ -0,0 +1,50 @@ +const assert = require('assert') +const migration27 = require('../../../app/scripts/migrations/027') + +const oldStorage = { + 'meta': {}, + 'data': { + 'TransactionController': { + 'transactions': [ + ], + }, + }, +} + +const transactions = [] + + +while (transactions.length < 9) { + transactions.push({status: 'rejected'}) + transactions.push({status: 'unapproved'}) + transactions.push({status: 'approved'}) +} + + +oldStorage.data.TransactionController.transactions = transactions + +describe('migration #27', () => { + it('should remove rejected transactions', (done) => { + migration27.migrate(oldStorage) + .then((newStorage) => { + const newTransactions = newStorage.data.TransactionController.transactions + assert.equal(newTransactions.length, 6, 'transactions is expected to have the length of 6') + newTransactions.forEach((txMeta) => { + if (txMeta.status === 'rejected') done(new Error('transaction was found with a status of rejected')) + }) + done() + }) + .catch(done) + }) + + it('should successfully migrate first time state', (done) => { + migration27.migrate({ + meta: {}, + data: require('../../../app/scripts/first-time-state'), + }) + .then((migratedData) => { + assert.equal(migratedData.meta.version, migration27.version) + done() + }).catch(done) + }) +}) diff --git a/ui/app/actions.js b/ui/app/actions.js index 9330a864b..4b2c53b96 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -811,11 +811,10 @@ function signTypedMsg (msgData) { function signTx (txData) { return (dispatch) => { - dispatch(actions.showLoadingIndication()) global.ethQuery.sendTransaction(txData, (err, data) => { - dispatch(actions.hideLoadingIndication()) - if (err) return dispatch(actions.displayWarning(err.message)) - dispatch(actions.hideWarning()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } }) dispatch(actions.showConfTxPage({})) } @@ -1017,29 +1016,41 @@ function signTokenTx (tokenAddress, toAddress, amount, txData) { function updateTransaction (txData) { log.info('actions: updateTx: ' + JSON.stringify(txData)) - return (dispatch) => { + return dispatch => { log.debug(`actions calling background.updateTx`) - background.updateTransaction(txData, (err) => { - dispatch(actions.hideLoadingIndication()) - dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) - if (err) { - dispatch(actions.txError(err)) - dispatch(actions.goHome()) - return log.error(err.message) - } - dispatch(actions.showConfTxPage({ id: txData.id })) + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + background.updateTransaction(txData, (err) => { + dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) + if (err) { + dispatch(actions.txError(err)) + dispatch(actions.goHome()) + log.error(err.message) + return reject(err) + } + + resolve(txData) + }) }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { + dispatch(actions.showConfTxPage({ id: txData.id })) + dispatch(actions.hideLoadingIndication()) + return txData + }) } } function updateAndApproveTx (txData) { log.info('actions: updateAndApproveTx: ' + JSON.stringify(txData)) - return (dispatch) => { + return dispatch => { log.debug(`actions calling background.updateAndApproveTx`) + dispatch(actions.showLoadingIndication()) return new Promise((resolve, reject) => { background.updateAndApproveTransaction(txData, err => { - dispatch(actions.hideLoadingIndication()) dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) dispatch(actions.clearSend()) @@ -1050,10 +1061,17 @@ function updateAndApproveTx (txData) { reject(err) } - dispatch(actions.completedTx(txData.id)) resolve(txData) }) }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { + dispatch(actions.clearSend()) + dispatch(actions.completedTx(txData.id)) + dispatch(actions.hideLoadingIndication()) + return txData + }) } } @@ -1145,13 +1163,25 @@ function cancelTypedMsg (msgData) { function cancelTx (txData) { return dispatch => { log.debug(`background.cancelTransaction`) + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { - background.cancelTransaction(txData.id, () => { + background.cancelTransaction(txData.id, err => { + if (err) { + return reject(err) + } + + resolve() + }) + }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { dispatch(actions.clearSend()) dispatch(actions.completedTx(txData.id)) - resolve(txData) + dispatch(actions.hideLoadingIndication()) + return txData }) - }) } } diff --git a/ui/app/app.js b/ui/app/app.js index 670b7e2d0..74d360d3c 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -12,7 +12,7 @@ const log = require('loglevel') const InitializeScreen = require('../../mascara/src/app/first-time').default // accounts const SendTransactionScreen = require('./components/send_/send.container') -const ConfirmTxScreen = require('./conf-tx') +const ConfirmTransaction = require('./components/pages/confirm-transaction') // slideout menu const WalletView = require('./components/wallet-view') @@ -22,7 +22,6 @@ const Home = require('./components/pages/home') const Authenticated = require('./components/pages/authenticated') const Initialized = require('./components/pages/initialized') const Settings = require('./components/pages/settings') -const UnlockPage = require('./components/pages/unlock-page') const RestoreVaultPage = require('./components/pages/keychains/restore-vault').default const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed') const AddTokenPage = require('./components/pages/add-token') @@ -40,6 +39,8 @@ const Modal = require('./components/modals/index').Modal const AppHeader = require('./components/app-header') +import UnlockPage from './components/pages/unlock-page' + // Routes const { DEFAULT_ROUTE, @@ -76,7 +77,10 @@ class App extends Component { h(Authenticated, { path: REVEAL_SEED_ROUTE, exact, component: RevealSeedConfirmation }), h(Authenticated, { path: SETTINGS_ROUTE, component: Settings }), h(Authenticated, { path: NOTICE_ROUTE, exact, component: NoticeScreen }), - h(Authenticated, { path: `${CONFIRM_TRANSACTION_ROUTE}/:id?`, component: ConfirmTxScreen }), + h(Authenticated, { + path: `${CONFIRM_TRANSACTION_ROUTE}/:id?`, + component: ConfirmTransaction, + }), h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }), h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }), h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }), diff --git a/ui/app/components/app-header/app-header.component.js b/ui/app/components/app-header/app-header.component.js index 62b04562a..07ca6cf84 100644 --- a/ui/app/components/app-header/app-header.component.js +++ b/ui/app/components/app-header/app-header.component.js @@ -91,7 +91,6 @@ class AppHeader extends Component { network, provider, history, - location, isUnlocked, } = this.props @@ -126,7 +125,7 @@ class AppHeader extends Component { network={network} provider={provider} onClick={event => this.handleNetworkIndicatorClick(event)} - disabled={location.pathname === CONFIRM_TRANSACTION_ROUTE} + disabled={this.isConfirming()} /> </div> { this.renderAccountMenu() } diff --git a/ui/app/components/button/button.component.js b/ui/app/components/button/button.component.js index e8e798445..1e0ef1b64 100644 --- a/ui/app/components/button/button.component.js +++ b/ui/app/components/button/button.component.js @@ -5,15 +5,24 @@ import classnames from 'classnames' const CLASSNAME_DEFAULT = 'btn-default' const CLASSNAME_PRIMARY = 'btn-primary' const CLASSNAME_SECONDARY = 'btn-secondary' +const CLASSNAME_CONFIRM = 'btn-confirm' const CLASSNAME_LARGE = 'btn--large' const typeHash = { default: CLASSNAME_DEFAULT, primary: CLASSNAME_PRIMARY, secondary: CLASSNAME_SECONDARY, + confirm: CLASSNAME_CONFIRM, } -class Button extends Component { +export default class Button extends Component { + static propTypes = { + type: PropTypes.string, + large: PropTypes.bool, + className: PropTypes.string, + children: PropTypes.string, + } + render () { const { type, large, className, ...buttonProps } = this.props @@ -31,13 +40,3 @@ class Button extends Component { ) } } - -Button.propTypes = { - type: PropTypes.string, - large: PropTypes.bool, - className: PropTypes.string, - children: PropTypes.string, -} - -export default Button - diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js b/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js new file mode 100644 index 000000000..631cf5803 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js @@ -0,0 +1,52 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +const ConfirmDetailRow = props => { + const { + label, + fiatFee, + ethFee, + onHeaderClick, + fiatFeeColor, + headerText, + headerTextClassName, + } = props + + return ( + <div className="confirm-detail-row"> + <div className="confirm-detail-row__label"> + { label } + </div> + <div className="confirm-detail-row__details"> + <div + className={classnames('confirm-detail-row__header-text', headerTextClassName)} + onClick={() => onHeaderClick && onHeaderClick()} + > + { headerText } + </div> + <div + className="confirm-detail-row__fiat" + style={{ color: fiatFeeColor }} + > + { fiatFee } + </div> + <div className="confirm-detail-row__eth"> + { `\u2666 ${ethFee}` } + </div> + </div> + </div> + ) +} + +ConfirmDetailRow.propTypes = { + label: PropTypes.string, + fiatFee: PropTypes.string, + ethFee: PropTypes.string, + fiatFeeColor: PropTypes.string, + onHeaderClick: PropTypes.func, + headerText: PropTypes.string, + headerTextClassName: PropTypes.string, +} + +export default ConfirmDetailRow diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/index.js b/ui/app/components/confirm-page-container/confirm-detail-row/index.js new file mode 100644 index 000000000..056afff04 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/index.js @@ -0,0 +1 @@ +export { default } from './confirm-detail-row.component' diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/index.scss b/ui/app/components/confirm-page-container/confirm-detail-row/index.scss new file mode 100644 index 000000000..84d0d56ed --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/index.scss @@ -0,0 +1,43 @@ +.confirm-detail-row { + padding: 14px 0; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + &__label { + font-size: .75rem; + font-weight: 500; + color: $scorpion; + text-transform: uppercase; + } + + &__details { + flex: 1; + text-align: end; + } + + &__fiat { + font-size: 1.5rem; + } + + &__eth { + color: $oslo-gray; + } + + &__header-text { + font-size: .75rem; + text-transform: uppercase; + margin-bottom: 6px; + color: $scorpion; + + &--edit { + color: $curious-blue; + cursor: pointer; + } + + &--total { + font-size: .625rem; + } + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js new file mode 100644 index 000000000..08923af88 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -0,0 +1,105 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { Tabs, Tab } from '../../tabs' +import { + ConfirmPageContainerSummary, + ConfirmPageContainerError, + ConfirmPageContainerWarning, +} from './' + +export default class ConfirmPageContainerContent extends Component { + static propTypes = { + action: PropTypes.string, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + hideSubtitle: PropTypes.bool, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, + subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + summaryComponent: PropTypes.node, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + titleComponent: PropTypes.func, + warning: PropTypes.string, + } + + renderContent () { + const { detailsComponent, dataComponent } = this.props + + if (detailsComponent && dataComponent) { + return this.renderTabs() + } else { + return detailsComponent || dataComponent + } + } + + renderTabs () { + const { detailsComponent, dataComponent } = this.props + + return ( + <Tabs> + <Tab name="Details"> + { detailsComponent } + </Tab> + <Tab name="Data"> + { dataComponent } + </Tab> + </Tabs> + ) + } + + render () { + const { + action, + errorKey, + errorMessage, + title, + subtitle, + hideSubtitle, + identiconAddress, + nonce, + summaryComponent, + detailsComponent, + dataComponent, + warning, + } = this.props + + return ( + <div className="confirm-page-container-content"> + { + warning && ( + <ConfirmPageContainerWarning warning={warning} /> + ) + } + { + summaryComponent || ( + <ConfirmPageContainerSummary + className={classnames({ + 'confirm-page-container-summary--border': !detailsComponent || !dataComponent, + })} + action={action} + title={title} + subtitle={subtitle} + hideSubtitle={hideSubtitle} + identiconAddress={identiconAddress} + nonce={nonce} + /> + ) + } + { this.renderContent() } + { + (errorKey || errorMessage) && ( + <div className="confirm-page-container-content__error-container"> + <ConfirmPageContainerError + errorMessage={errorMessage} + errorKey={errorKey} + /> + </div> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/confirm-page-container-error.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/confirm-page-container-error.component.js new file mode 100644 index 000000000..70ebdeb20 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/confirm-page-container-error.component.js @@ -0,0 +1,28 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const ConfirmPageContainerError = (props, context) => { + const { errorMessage, errorKey } = props + const error = errorKey ? context.t(errorKey) : errorMessage + + return ( + <div className="confirm-page-container-error"> + <img + src="/images/alert-red.svg" + className="confirm-page-container-error__icon" + /> + { `ALERT: ${error}` } + </div> + ) +} + +ConfirmPageContainerError.propTypes = { + errorMessage: PropTypes.string, + errorKey: PropTypes.string, +} + +ConfirmPageContainerError.contextTypes = { + t: PropTypes.func, +} + +export default ConfirmPageContainerError diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.js new file mode 100644 index 000000000..4ac95d0e3 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-error.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss new file mode 100644 index 000000000..e99b0f631 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss @@ -0,0 +1,17 @@ +.confirm-page-container-error { + height: 32px; + border: 1px solid $monzo; + color: $monzo; + background: lighten($monzo, 56%); + border-radius: 4px; + font-size: .75rem; + display: flex; + justify-content: flex-start; + align-items: center; + padding-left: 16px; + + &__icon { + margin-right: 8px; + flex: 0 0 auto; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js new file mode 100644 index 000000000..3b1ee62c5 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js @@ -0,0 +1,56 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Identicon from '../../../identicon' + +const ConfirmPageContainerSummary = props => { + const { action, title, subtitle, hideSubtitle, className, identiconAddress, nonce } = props + + return ( + <div className={classnames('confirm-page-container-summary', className)}> + <div className="confirm-page-container-summary__action-row"> + <div className="confirm-page-container-summary__action"> + { action } + </div> + { + nonce && ( + <div className="confirm-page-container-summary__nonce"> + { `#${nonce}` } + </div> + ) + } + </div> + <div className="confirm-page-container-summary__title"> + { + identiconAddress && ( + <Identicon + className="confirm-page-container-summary__identicon" + diameter={36} + address={identiconAddress} + /> + ) + } + <div className="confirm-page-container-summary__title-text"> + { title } + </div> + </div> + { + hideSubtitle || <div className="confirm-page-container-summary__subtitle"> + { subtitle } + </div> + } + </div> + ) +} + +ConfirmPageContainerSummary.propTypes = { + action: PropTypes.string, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hideSubtitle: PropTypes.bool, + className: PropTypes.string, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, +} + +export default ConfirmPageContainerSummary diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js new file mode 100644 index 000000000..ed1b28cf2 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-summary.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss new file mode 100644 index 000000000..7f0f5d37a --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss @@ -0,0 +1,54 @@ +.confirm-page-container-summary { + padding: 16px 24px 0; + background-color: #f9fafa; + height: 133px; + box-sizing: border-box; + + &__action-row { + display: flex; + justify-content: space-between; + } + + &__action { + text-transform: uppercase; + color: $oslo-gray; + font-size: .75rem; + padding: 3px 8px; + border: 1px solid $oslo-gray; + border-radius: 4px; + display: inline-block; + } + + &__nonce { + color: $oslo-gray; + } + + &__title { + padding: 4px 0; + display: flex; + align-items: center; + } + + &__identicon { + flex: 0 0 auto; + margin-right: 8px; + } + + &__title-text { + font-size: 2.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__subtitle { + color: $oslo-gray; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &--border { + border-bottom: 1px solid $geyser; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js new file mode 100644 index 000000000..79901c8fc --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js @@ -0,0 +1,22 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const ConfirmPageContainerWarning = props => { + return ( + <div className="confirm-page-container-warning"> + <img + className="confirm-page-container-warning__icon" + src="/images/alert.svg" + /> + <div className="confirm-page-container-warning__warning"> + { props.warning } + </div> + </div> + ) +} + +ConfirmPageContainerWarning.propTypes = { + warning: PropTypes.string, +} + +export default ConfirmPageContainerWarning diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js new file mode 100644 index 000000000..6e48bd144 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-warning.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss new file mode 100644 index 000000000..50545a1a2 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss @@ -0,0 +1,18 @@ +.confirm-page-container-warning { + background-color: #fffcdb; + display: flex; + justify-content: center; + align-items: center; + border-bottom: 1px solid $geyser; + padding: 12px 24px; + + &__icon { + flex: 0 0 auto; + margin-right: 16px; + } + + &__warning { + font-size: .75rem; + color: #5f5922; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/index.js new file mode 100644 index 000000000..1469dd438 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/index.js @@ -0,0 +1,4 @@ +export { default } from './confirm-page-container-content.component' +export { default as ConfirmPageContainerSummary } from './confirm-page-container-summary' +export { default as ConfirmPageContainerError } from './confirm-page-container-error' +export { default as ConfirmPageContainerWarning } from './confirm-page-container-warning' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss new file mode 100644 index 000000000..39797a43f --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss @@ -0,0 +1,66 @@ +@import './confirm-page-container-error/index'; + +@import './confirm-page-container-warning/index'; + +@import './confirm-page-container-summary/index'; + +.confirm-page-container-content { + overflow-y: auto; + flex: 1; + + &__error-container { + padding: 0 16px 16px 16px; + } + + &__details { + box-sizing: border-box; + padding: 0 24px; + } + + &__data { + padding: 16px; + color: $oslo-gray; + } + + &__data-box { + background-color: #f9fafa; + padding: 12px; + font-size: .75rem; + margin-bottom: 16px; + word-wrap: break-word; + max-height: 200px; + overflow-y: auto; + + &-label { + text-transform: uppercase; + padding: 8px 0 12px; + font-size: 12px; + } + } + + &__data-field { + display: flex; + flex-direction: row; + + &-label { + font-weight: 500; + padding-right: 16px; + } + + &:not(:last-child) { + margin-bottom: 5px; + } + } + + &__gas-fee { + border-bottom: 1px solid $geyser; + } + + &__function-type { + font-size: .875rem; + font-weight: 500; + text-transform: capitalize; + color: $black; + padding-left: 5px; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js b/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js new file mode 100644 index 000000000..e6fe8f82c --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { + ENVIRONMENT_TYPE_POPUP, + ENVIRONMENT_TYPE_NOTIFICATION, +} from '../../../../../app/scripts/lib/enums' +import NetworkDisplay from '../../network-display' + +export default class ConfirmPageContainer extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + showEdit: PropTypes.bool, + onEdit: PropTypes.func, + children: PropTypes.node, + } + + renderTop () { + const { onEdit, showEdit } = this.props + const windowType = window.METAMASK_UI_TYPE + const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && + windowType !== ENVIRONMENT_TYPE_POPUP + + if (!showEdit && isFullScreen) { + return null + } + + return ( + <div className="confirm-page-container-header__row"> + <div + className="confirm-page-container-header__back-button-container" + style={{ + visibility: showEdit ? 'initial' : 'hidden', + }} + > + <img + src="/images/caret-left.svg" + /> + <span + className="confirm-page-container-header__back-button" + onClick={() => onEdit()} + > + { this.context.t('edit') } + </span> + </div> + { !isFullScreen && <NetworkDisplay /> } + </div> + ) + } + + render () { + const { children } = this.props + + return ( + <div className="confirm-page-container-header"> + { this.renderTop() } + { children } + </div> + ) + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/index.js b/ui/app/components/confirm-page-container/confirm-page-container-header/index.js new file mode 100644 index 000000000..71feb6931 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-header/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-header.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss new file mode 100644 index 000000000..43e1e4427 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss @@ -0,0 +1,27 @@ +.confirm-page-container-header { + display: flex; + flex-direction: column; + flex: 0 0 auto; + + &__row { + display: flex; + justify-content: space-between; + border-bottom: 1px solid $geyser; + padding: 13px 13px 13px 24px; + flex: 0 0 auto; + } + + &__back-button-container { + display: flex; + justify-content: center; + align-items: center; + } + + &__back-button { + color: #2f9ae0; + font-size: 1rem; + cursor: pointer; + font-weight: 400; + padding-left: 5px; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container.component.js b/ui/app/components/confirm-page-container/confirm-page-container.component.js new file mode 100644 index 000000000..93e4ae7bf --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container.component.js @@ -0,0 +1,118 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SenderToRecipient from '../sender-to-recipient' +import { PageContainerFooter } from '../page-container' +import { ConfirmPageContainerHeader, ConfirmPageContainerContent } from './' + +export default class ConfirmPageContainer extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + // Header + action: PropTypes.string, + hideSubtitle: PropTypes.bool, + onEdit: PropTypes.func, + showEdit: PropTypes.bool, + subtitle: PropTypes.string, + title: PropTypes.string, + titleComponent: PropTypes.func, + // Sender to Recipient + fromAddress: PropTypes.string, + fromName: PropTypes.string, + toAddress: PropTypes.string, + toName: PropTypes.string, + // Content + contentComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + fiatTransactionAmount: PropTypes.string, + fiatTransactionFee: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + ethTransactionAmount: PropTypes.string, + ethTransactionFee: PropTypes.string, + ethTransactionTotal: PropTypes.string, + onEditGas: PropTypes.func, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, + summaryComponent: PropTypes.node, + warning: PropTypes.string, + // Footer + onCancel: PropTypes.func, + onSubmit: PropTypes.func, + valid: PropTypes.bool, + } + + render () { + const { + showEdit, + onEdit, + fromName, + fromAddress, + toName, + toAddress, + valid, + errorKey, + errorMessage, + contentComponent, + action, + title, + titleComponent, + subtitle, + hideSubtitle, + summaryComponent, + detailsComponent, + dataComponent, + onCancel, + onSubmit, + identiconAddress, + nonce, + warning, + } = this.props + + return ( + <div className="page-container"> + <ConfirmPageContainerHeader + showEdit={showEdit} + onEdit={() => onEdit()} + > + <SenderToRecipient + senderName={fromName} + senderAddress={fromAddress} + recipientName={toName} + recipientAddress={toAddress} + /> + </ConfirmPageContainerHeader> + { + contentComponent || ( + <ConfirmPageContainerContent + action={action} + title={title} + titleComponent={titleComponent} + subtitle={subtitle} + hideSubtitle={hideSubtitle} + summaryComponent={summaryComponent} + detailsComponent={detailsComponent} + dataComponent={dataComponent} + errorMessage={errorMessage} + errorKey={errorKey} + identiconAddress={identiconAddress} + nonce={nonce} + warning={warning} + /> + ) + } + <PageContainerFooter + onCancel={() => onCancel()} + onSubmit={() => onSubmit()} + submitText={this.context.t('confirm')} + submitButtonType="confirm" + disabled={!valid} + /> + </div> + ) + } +} diff --git a/ui/app/components/confirm-page-container/index.js b/ui/app/components/confirm-page-container/index.js new file mode 100644 index 000000000..ee88aa5d3 --- /dev/null +++ b/ui/app/components/confirm-page-container/index.js @@ -0,0 +1,8 @@ +export { default } from './confirm-page-container.component' +export { default as ConfirmPageContainerHeader } from './confirm-page-container-header' +export { default as ConfirmDetailRow } from './confirm-detail-row' +export { + default as ConfirmPageContainerContent, + ConfirmPageContainerSummary, + ConfirmPageContainerError, +} from './confirm-page-container-content' diff --git a/ui/app/components/confirm-page-container/index.scss b/ui/app/components/confirm-page-container/index.scss new file mode 100644 index 000000000..af7a5b555 --- /dev/null +++ b/ui/app/components/confirm-page-container/index.scss @@ -0,0 +1,5 @@ +@import './confirm-page-container-content/index'; + +@import './confirm-page-container-header/index'; + +@import './confirm-detail-row/index'; diff --git a/ui/app/components/dropdowns/components/network-dropdown-icon.js b/ui/app/components/dropdowns/components/network-dropdown-icon.js index 7e94e0af5..a45da4c10 100644 --- a/ui/app/components/dropdowns/components/network-dropdown-icon.js +++ b/ui/app/components/dropdowns/components/network-dropdown-icon.js @@ -15,6 +15,7 @@ NetworkDropdownIcon.prototype.render = function () { backgroundColor, isSelected, innerBorder = 'none', + diameter = '12', } = this.props return h(`.menu-icon-circle${isSelected ? '--active' : ''}`, {}, @@ -22,6 +23,8 @@ NetworkDropdownIcon.prototype.render = function () { style: { background: backgroundColor, border: innerBorder, + height: `${diameter}px`, + width: `${diameter}px`, }, }) ) diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss index 351640f6e..32f0e90e4 100644 --- a/ui/app/components/index.scss +++ b/ui/app/components/index.scss @@ -4,6 +4,16 @@ @import './info-box/index'; +@import './network-display/index'; + +@import './confirm-page-container/index'; + +@import './page-container/index'; + @import './pages/index'; @import './modals/index'; + +@import './sender-to-recipient/index'; + +@import './tabs/index'; diff --git a/ui/app/components/modals/customize-gas/customize-gas.component.js b/ui/app/components/modals/customize-gas/customize-gas.component.js new file mode 100644 index 000000000..d17c290b6 --- /dev/null +++ b/ui/app/components/modals/customize-gas/customize-gas.component.js @@ -0,0 +1,140 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import GasModalCard from '../../customize-gas-modal/gas-modal-card' +import { MIN_GAS_PRICE_GWEI } from '../../send_/send.constants' + +import { + getDecimalGasLimit, + getDecimalGasPrice, + getPrefixedHexGasLimit, + getPrefixedHexGasPrice, +} from './customize-gas.util' + +export default class CustomizeGas extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + txData: PropTypes.object.isRequired, + hideModal: PropTypes.func, + validate: PropTypes.func, + onSubmit: PropTypes.func, + } + + state = { + gasPrice: 0, + gasLimit: 0, + originalGasPrice: 0, + originalGasLimit: 0, + } + + componentDidMount () { + const { txData = {} } = this.props + const { txParams: { gas: hexGasLimit, gasPrice: hexGasPrice } = {} } = txData + + const gasLimit = getDecimalGasLimit(hexGasLimit) + const gasPrice = getDecimalGasPrice(hexGasPrice) + + this.setState({ + gasPrice, + gasLimit, + originalGasPrice: gasPrice, + originalGasLimit: gasLimit, + }) + } + + handleRevert () { + const { originalGasPrice, originalGasLimit } = this.state + + this.setState({ + gasPrice: originalGasPrice, + gasLimit: originalGasLimit, + }) + } + + handleSave () { + const { onSubmit, hideModal } = this.props + const { gasLimit, gasPrice } = this.state + const prefixedHexGasPrice = getPrefixedHexGasPrice(gasPrice) + const prefixedHexGasLimit = getPrefixedHexGasLimit(gasLimit) + + Promise.resolve(onSubmit({ gasPrice: prefixedHexGasPrice, gasLimit: prefixedHexGasLimit })) + .then(() => hideModal()) + } + + validate () { + const { gasLimit, gasPrice } = this.state + return this.props.validate({ + gasPrice: getPrefixedHexGasPrice(gasPrice), + gasLimit: getPrefixedHexGasLimit(gasLimit), + }) + } + + render () { + const { t } = this.context + const { hideModal } = this.props + const { gasPrice, gasLimit } = this.state + const { valid, errorKey } = this.validate() + + return ( + <div className="customize-gas"> + <div className="customize-gas__content"> + <div className="customize-gas__header"> + <div className="customize-gas__title"> + { this.context.t('customGas') } + </div> + <div + className="customize-gas__close" + onClick={() => hideModal()} + /> + </div> + <div className="customize-gas__body"> + <GasModalCard + value={gasPrice} + min={MIN_GAS_PRICE_GWEI} + step={1} + onChange={value => this.setState({ gasPrice: value })} + title={t('gasPrice')} + copy={t('gasPriceCalculation')} + /> + <GasModalCard + value={gasLimit} + min={1} + step={1} + onChange={value => this.setState({ gasLimit: value })} + title={t('gasLimit')} + copy={t('gasLimitCalculation')} + /> + </div> + <div className="customize-gas__footer"> + { !valid && <div className="customize-gas__error-message">{ t(errorKey) }</div> } + <div + className="customize-gas__revert" + onClick={() => this.handleRevert()} + > + { t('revert') } + </div> + <div className="customize-gas__buttons"> + <button + className="btn-default customize-gas__cancel" + onClick={() => hideModal()} + style={{ marginRight: '10px' }} + > + { t('cancel') } + </button> + <button + className="btn-primary customize-gas__save" + onClick={() => this.handleSave()} + style={{ marginRight: '10px' }} + disabled={!valid} + > + { t('save') } + </button> + </div> + </div> + </div> + </div> + ) + } +} diff --git a/ui/app/components/modals/customize-gas/customize-gas.container.js b/ui/app/components/modals/customize-gas/customize-gas.container.js new file mode 100644 index 000000000..46a799795 --- /dev/null +++ b/ui/app/components/modals/customize-gas/customize-gas.container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux' +import CustomizeGas from './customize-gas.component' +import { hideModal } from '../../../actions' + +const mapStateToProps = state => { + const { appState: { modal: { modalState: { props } } } } = state + const { txData, onSubmit, validate } = props + + return { + txData, + onSubmit, + validate, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(CustomizeGas) diff --git a/ui/app/components/modals/customize-gas/customize-gas.util.js b/ui/app/components/modals/customize-gas/customize-gas.util.js new file mode 100644 index 000000000..6ba4a7705 --- /dev/null +++ b/ui/app/components/modals/customize-gas/customize-gas.util.js @@ -0,0 +1,34 @@ +import ethUtil from 'ethereumjs-util' +import { conversionUtil } from '../../../conversion-util' + +export function getDecimalGasLimit (hexGasLimit) { + return conversionUtil(hexGasLimit, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) +} + +export function getDecimalGasPrice (hexGasPrice) { + return conversionUtil(hexGasPrice, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + toDenomination: 'GWEI', + }) +} + +export function getPrefixedHexGasLimit (gasLimit) { + return ethUtil.addHexPrefix(conversionUtil(gasLimit, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + })) +} + +export function getPrefixedHexGasPrice (gasPrice) { + return ethUtil.addHexPrefix(conversionUtil(gasPrice, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + })) +} diff --git a/ui/app/components/modals/customize-gas/index.js b/ui/app/components/modals/customize-gas/index.js new file mode 100644 index 000000000..3a0ab7edc --- /dev/null +++ b/ui/app/components/modals/customize-gas/index.js @@ -0,0 +1 @@ +export { default } from './customize-gas.container' diff --git a/ui/app/components/modals/customize-gas/index.scss b/ui/app/components/modals/customize-gas/index.scss new file mode 100644 index 000000000..e10452691 --- /dev/null +++ b/ui/app/components/modals/customize-gas/index.scss @@ -0,0 +1,110 @@ +.customize-gas { + border: 1px solid #D8D8D8; + border-radius: 4px; + background-color: #FFFFFF; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.14); + font-family: Roboto; + display: flex; + flex-flow: column; + + @media screen and (max-width: $break-small) { + width: 100vw; + height: 100vh; + } + + &__header { + height: 52px; + border-bottom: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + + @media screen and (max-width: $break-small) { + flex: 0 0 auto; + } + } + + &__title { + margin-left: 19.25px; + } + + &__close::after { + content: '\00D7'; + font-size: 1.8em; + color: $dusty-gray; + font-family: sans-serif; + cursor: pointer; + margin-right: 19.25px; + } + + &__content { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + + &__body { + display: flex; + margin-bottom: 24px; + + @media screen and (max-width: $break-small) { + flex-flow: column; + flex: 1 1 auto; + } + } + + &__footer { + height: 75px; + border-top: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + position: relative; + + @media screen and (max-width: $break-small) { + flex: 0 0 auto; + } + } + + &__buttons { + display: flex; + justify-content: space-between; + margin-right: 21.25px; + } + + &__revert, &__cancel, &__save, &__save__error { + display: flex; + justify-content: center; + align-items: center; + padding: 0 3px; + cursor: pointer; + } + + &__revert { + color: $silver-chalice; + font-size: 16px; + margin-left: 21.25px; + } + + &__cancel, &__save, &__save__error { + width: 85.74px; + min-width: initial; + } + + &__save__error { + opacity: 0.5; + cursor: auto; + } + + &__error-message { + display: block; + position: absolute; + top: 4px; + right: 4px; + font-size: 12px; + line-height: 12px; + color: $red; + } +} diff --git a/ui/app/components/modals/index.scss b/ui/app/components/modals/index.scss index 591e35148..b7f1f249d 100644 --- a/ui/app/components/modals/index.scss +++ b/ui/app/components/modals/index.scss @@ -1,3 +1,5 @@ +@import './customize-gas/index'; + .modal-container { width: 100%; height: 100%; diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index e40944165..f59825ed1 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -25,6 +25,8 @@ const TransactionConfirmed = require('./transaction-confirmed') const WelcomeBeta = require('./welcome-beta') const Notification = require('./notification') +import ConfirmCustomizeGasModal from './customize-gas' + const modalContainerBaseStyle = { transform: 'translate3d(-50%, 0, 0px)', border: '1px solid #CCCFD1', @@ -281,7 +283,31 @@ const MODALS = { CUSTOMIZE_GAS: { contents: [ - h(CustomizeGasModal, {}, []), + h(CustomizeGasModal), + ], + mobileModalStyle: { + width: '100vw', + height: '100vh', + top: '0', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '720px', + height: '377px', + top: '80px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + + CONFIRM_CUSTOMIZE_GAS: { + contents: [ + h(ConfirmCustomizeGasModal), ], mobileModalStyle: { width: '100vw', diff --git a/ui/app/components/network-display.js b/ui/app/components/network-display.js deleted file mode 100644 index 59719d9a4..000000000 --- a/ui/app/components/network-display.js +++ /dev/null @@ -1,56 +0,0 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const NetworkDropdownIcon = require('./dropdowns/components/network-dropdown-icon') - -const networkToColorHash = { - 1: '#038789', - 3: '#e91550', - 42: '#690496', - 4: '#ebb33f', -} - -class NetworkDisplay extends Component { - renderNetworkIcon () { - const { network } = this.props - const networkColor = networkToColorHash[network] - - return networkColor - ? h(NetworkDropdownIcon, { backgroundColor: networkColor }) - : h('i.fa.fa-question-circle.fa-med', { - style: { - margin: '0 4px', - color: 'rgb(125, 128, 130)', - }, - }) - } - - render () { - const { provider: { type } } = this.props - return h('.network-display__container', [ - this.renderNetworkIcon(), - h('.network-name', this.context.t(type)), - ]) - } -} - -NetworkDisplay.propTypes = { - network: PropTypes.string, - provider: PropTypes.object, - t: PropTypes.func, -} - -const mapStateToProps = ({ metamask: { network, provider } }) => { - return { - network, - provider, - } -} - -NetworkDisplay.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(NetworkDisplay) - diff --git a/ui/app/components/network-display/index.js b/ui/app/components/network-display/index.js new file mode 100644 index 000000000..f6878ae5b --- /dev/null +++ b/ui/app/components/network-display/index.js @@ -0,0 +1,2 @@ +import NetworkDisplay from './network-display.container' +module.exports = NetworkDisplay diff --git a/ui/app/components/network-display/index.scss b/ui/app/components/network-display/index.scss new file mode 100644 index 000000000..e82d0e70c --- /dev/null +++ b/ui/app/components/network-display/index.scss @@ -0,0 +1,54 @@ +.network-display { + &__container { + display: flex; + align-items: center; + justify-content: flex-start; + background-color: lighten(rgb(125, 128, 130), 45%); + padding: 0 10px; + border-radius: 4px; + height: 25px; + + &--mainnet { + background-color: lighten($blue-lagoon, 45%); + } + + &--ropsten { + background-color: lighten($crimson, 45%); + } + + &--kovan { + background-color: lighten($purple, 45%); + } + + &--rinkeby { + background-color: lighten($tulip-tree, 45%); + } + } + + &__name { + font-size: .75rem; + padding-left: 5px; + } + + &__icon { + height: 10px; + width: 10px; + border-radius: 10px; + + &--mainnet { + background-color: $blue-lagoon; + } + + &--ropsten { + background-color: $crimson; + } + + &--kovan { + background-color: $purple; + } + + &--rinkeby { + background-color: $tulip-tree; + } + } +} diff --git a/ui/app/components/network-display/network-display.component.js b/ui/app/components/network-display/network-display.component.js new file mode 100644 index 000000000..38626af20 --- /dev/null +++ b/ui/app/components/network-display/network-display.component.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { + MAINNET_CODE, + ROPSTEN_CODE, + RINKEYBY_CODE, + KOVAN_CODE, +} from '../../../../app/scripts/controllers/network/enums' + +const networkToClassHash = { + [MAINNET_CODE]: 'mainnet', + [ROPSTEN_CODE]: 'ropsten', + [RINKEYBY_CODE]: 'rinkeby', + [KOVAN_CODE]: 'kovan', +} + +export default class NetworkDisplay extends Component { + static propTypes = { + network: PropTypes.string, + provider: PropTypes.object, + } + + static contextTypes = { + t: PropTypes.func, + } + + renderNetworkIcon () { + const { network } = this.props + const networkClass = networkToClassHash[network] + + return networkClass + ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> + : <div + className="i fa fa-question-circle fa-med" + style={{ + margin: '0 4px', + color: 'rgb(125, 128, 130)', + }} + /> + } + + render () { + const { network, provider: { type } } = this.props + const networkClass = networkToClassHash[network] + + return ( + <div className={classnames( + 'network-display__container', + networkClass && ('network-display__container--' + networkClass) + )}> + { + networkClass + ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> + : <div + className="i fa fa-question-circle fa-med" + style={{ + margin: '0 4px', + color: 'rgb(125, 128, 130)', + }} + /> + } + <div className="network-display__name"> + { this.context.t(type) } + </div> + </div> + ) + } +} diff --git a/ui/app/components/network-display/network-display.container.js b/ui/app/components/network-display/network-display.container.js new file mode 100644 index 000000000..99a14fff4 --- /dev/null +++ b/ui/app/components/network-display/network-display.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import NetworkDisplay from './network-display.component' + +const mapStateToProps = ({ metamask: { network, provider } }) => { + return { + network, + provider, + } +} + +export default connect(mapStateToProps)(NetworkDisplay) diff --git a/ui/app/components/page-container/index.js b/ui/app/components/page-container/index.js index 415870b37..913b8c9c6 100644 --- a/ui/app/components/page-container/index.js +++ b/ui/app/components/page-container/index.js @@ -1 +1,4 @@ +import PageContainerHeader from './page-container-header' +import PageContainerFooter from './page-container-footer' export { default } from './page-container.component' +export { PageContainerHeader, PageContainerFooter } diff --git a/ui/app/components/page-container/index.scss b/ui/app/components/page-container/index.scss new file mode 100644 index 000000000..06c3ef709 --- /dev/null +++ b/ui/app/components/page-container/index.scss @@ -0,0 +1,186 @@ +.page-container { + width: 408px; + background-color: $white; + box-shadow: 0 0 7px 0 rgba(0, 0, 0, .08); + z-index: 25; + display: flex; + flex-flow: column; + border-radius: 8px; + + &__header { + display: flex; + flex-flow: column; + border-bottom: 1px solid $geyser; + padding: 16px; + flex: 0 0 auto; + position: relative; + + &--no-padding-bottom { + padding-bottom: 0; + } + } + + &__header-close { + color: $tundora; + position: absolute; + top: 16px; + right: 16px; + cursor: pointer; + overflow: hidden; + + &::after { + content: '\00D7'; + font-size: 40px; + line-height: 20px; + } + } + + &__header-row { + padding-bottom: 10px; + display: flex; + justify-content: space-between; + } + + &__footer { + display: flex; + flex-flow: row; + justify-content: center; + border-top: 1px solid $geyser; + padding: 16px; + flex: 0 0 auto; + + .btn-default, + .btn-confirm { + font-size: 1rem; + } + } + + &__footer-button { + height: 55px; + font-size: 1rem; + text-transform: uppercase; + margin-right: 16px; + + &:last-of-type { + margin-right: 0; + } + } + + &__back-button { + color: #2f9ae0; + font-size: 1rem; + cursor: pointer; + font-weight: 400; + } + + &__title { + color: $black; + font-size: 2rem; + font-weight: 500; + line-height: 2rem; + } + + &__subtitle { + padding-top: .5rem; + line-height: initial; + font-size: .9rem; + color: $gray; + } + + &__tabs { + display: flex; + margin-top: 16px; + } + + &__tab { + min-width: 5rem; + padding: 8px; + color: $dusty-gray; + font-family: Roboto; + font-size: 1rem; + text-align: center; + cursor: pointer; + border-bottom: none; + margin-right: 16px; + + &:last-of-type { + margin-right: 0; + } + + &--selected { + color: $curious-blue; + border-bottom: 3px solid $curious-blue; + } + } + + &--full-width { + width: 100% !important; + } + + &--full-height { + height: 100% !important; + max-height: initial !important; + min-height: initial !important; + } + + &__content { + overflow-y: auto; + flex: 1; + } + + &__warning-container { + background: $linen; + padding: 20px; + display: flex; + align-items: start; + } + + &__warning-message { + padding-left: 15px; + } + + &__warning-title { + font-weight: 500; + } + + &__warning-icon { + padding-top: 5px; + } +} + +@media screen and (max-width: 250px) { + .page-container { + &__footer { + flex-flow: column-reverse; + } + + &__footer-button { + width: 100%; + margin-bottom: 1rem; + margin-right: 0; + + &:first-of-type { + margin-bottom: 0; + } + } + } +} + +@media screen and (max-width: 575px) { + .page-container { + height: 100%; + width: 100%; + overflow-y: auto; + background-color: $white; + border-radius: 0; + flex: 1; + } +} + +@media screen and (min-width: 576px) { + .page-container { + max-height: 82vh; + min-height: 570px; + flex: 0 0 auto; + } +} diff --git a/ui/app/components/page-container/page-container-footer/page-container-footer.component.js b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js index 0458ae78a..3d15df294 100644 --- a/ui/app/components/page-container/page-container-footer/page-container-footer.component.js +++ b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js @@ -10,6 +10,7 @@ export default class PageContainerFooter extends Component { onSubmit: PropTypes.func, submitText: PropTypes.string, disabled: PropTypes.bool, + submitButtonType: PropTypes.string, } static contextTypes = { @@ -23,6 +24,7 @@ export default class PageContainerFooter extends Component { onSubmit, submitText, disabled, + submitButtonType, } = this.props return ( @@ -30,16 +32,16 @@ export default class PageContainerFooter extends Component { <Button type="default" - large={true} + large className="page-container__footer-button" - onClick={() => onCancel()} + onClick={e => onCancel(e)} > { cancelText || this.context.t('cancel') } </Button> <Button - type="primary" - large={true} + type={submitButtonType || 'primary'} + large className="page-container__footer-button" disabled={disabled} onClick={e => onSubmit(e)} diff --git a/ui/app/components/page-container/page-container-header.component.js b/ui/app/components/page-container/page-container-header.component.js deleted file mode 100644 index 5c9d63221..000000000 --- a/ui/app/components/page-container/page-container-header.component.js +++ /dev/null @@ -1,35 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' - -export default class PageContainerHeader extends Component { - - static propTypes = { - title: PropTypes.string, - subtitle: PropTypes.string, - onClose: PropTypes.func, - }; - - render () { - const { title, subtitle, onClose } = this.props - - return ( - <div className="page-container__header"> - - <div className="page-container__title"> - {title} - </div> - - <div className="page-container__subtitle"> - {subtitle} - </div> - - <div - className="page-container__header-close" - onClick={() => onClose()} - /> - - </div> - ) - } - -} diff --git a/ui/app/components/page-container/page-container-header/page-container-header.component.js b/ui/app/components/page-container/page-container-header/page-container-header.component.js index 28882edce..5a5de1e5a 100644 --- a/ui/app/components/page-container/page-container-header/page-container-header.component.js +++ b/ui/app/components/page-container/page-container-header/page-container-header.component.js @@ -4,13 +4,14 @@ import PropTypes from 'prop-types' export default class PageContainerHeader extends Component { static propTypes = { - title: PropTypes.string.isRequired, + title: PropTypes.string, subtitle: PropTypes.string, onClose: PropTypes.func, showBackButton: PropTypes.bool, onBackButtonClick: PropTypes.func, backButtonStyles: PropTypes.object, backButtonString: PropTypes.string, + children: PropTypes.node, }; renderHeaderRow () { @@ -30,25 +31,33 @@ export default class PageContainerHeader extends Component { } render () { - const { title, subtitle, onClose } = this.props + const { title, subtitle, onClose, children } = this.props return ( <div className="page-container__header"> { this.renderHeaderRow() } - <div className="page-container__title"> - {title} - </div> + { children } - <div className="page-container__subtitle"> - {subtitle} - </div> + { + title && <div className="page-container__title"> + { title } + </div> + } - <div - className="page-container__header-close" - onClick={() => onClose()} - /> + { + subtitle && <div className="page-container__subtitle"> + { subtitle } + </div> + } + + { + onClose && <div + className="page-container__header-close" + onClick={() => onClose()} + /> + } </div> ) diff --git a/ui/app/components/pages/confirm-approve/confirm-approve.component.js b/ui/app/components/pages/confirm-approve/confirm-approve.component.js new file mode 100644 index 000000000..d775b0362 --- /dev/null +++ b/ui/app/components/pages/confirm-approve/confirm-approve.component.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' + +export default class ConfirmApprove extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + tokenAddress: PropTypes.string, + toAddress: PropTypes.string, + tokenAmount: PropTypes.string, + tokenSymbol: PropTypes.string, + } + + render () { + const { toAddress, tokenAddress, tokenAmount, tokenSymbol } = this.props + + return ( + <ConfirmTransactionBase + toAddress={toAddress} + identiconAddress={tokenAddress} + title={`${tokenAmount} ${tokenSymbol}`} + warning={`By approving this action, you grant permission for this contract to spend up to ${tokenAmount} of your ${tokenSymbol}.`} + hideSubtitle + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-approve/confirm-approve.container.js b/ui/app/components/pages/confirm-approve/confirm-approve.container.js new file mode 100644 index 000000000..040e499ae --- /dev/null +++ b/ui/app/components/pages/confirm-approve/confirm-approve.container.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux' +import ConfirmApprove from './confirm-approve.component' + +const mapStateToProps = state => { + const { confirmTransaction } = state + const { + tokenData = {}, + txData: { txParams: { to: tokenAddress } = {} } = {}, + tokenProps: { tokenSymbol } = {}, + } = confirmTransaction + const { params = [] } = tokenData + + let toAddress = '' + let tokenAmount = '' + + if (params && params.length === 2) { + [{ value: toAddress }, { value: tokenAmount }] = params + } + + return { + toAddress, + tokenAddress, + tokenAmount, + tokenSymbol, + } +} + +export default connect(mapStateToProps)(ConfirmApprove) diff --git a/ui/app/components/pages/confirm-approve/index.js b/ui/app/components/pages/confirm-approve/index.js new file mode 100644 index 000000000..791297be7 --- /dev/null +++ b/ui/app/components/pages/confirm-approve/index.js @@ -0,0 +1 @@ +export { default } from './confirm-approve.container' diff --git a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js new file mode 100644 index 000000000..9bc0daab9 --- /dev/null +++ b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ethUtil from 'ethereumjs-util' +import ConfirmTransactionBase from '../confirm-transaction-base' + +export default class ConfirmDeployContract extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + txData: PropTypes.object, + } + + renderData () { + const { t } = this.context + const { + txData: { + origin, + txParams: { + data, + } = {}, + } = {}, + } = this.props + + return ( + <div className="confirm-page-container-content__data"> + <div className="confirm-page-container-content__data-box"> + <div className="confirm-page-container-content__data-field"> + <div className="confirm-page-container-content__data-field-label"> + { `${t('origin')}:` } + </div> + <div> + { origin } + </div> + </div> + <div className="confirm-page-container-content__data-field"> + <div className="confirm-page-container-content__data-field-label"> + { `${t('bytes')}:` } + </div> + <div> + { ethUtil.toBuffer(data).length } + </div> + </div> + </div> + <div className="confirm-page-container-content__data-box-label"> + { `${t('hexData')}:` } + </div> + <div className="confirm-page-container-content__data-box"> + { data } + </div> + </div> + ) + } + + render () { + return ( + <ConfirmTransactionBase + action={this.context.t('contractDeployment')} + dataComponent={this.renderData()} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js new file mode 100644 index 000000000..336ee83ea --- /dev/null +++ b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import ConfirmDeployContract from './confirm-deploy-contract.component' + +const mapStateToProps = state => { + const { confirmTransaction: { txData } = {} } = state + + return { + txData, + } +} + +export default connect(mapStateToProps)(ConfirmDeployContract) diff --git a/ui/app/components/pages/confirm-deploy-contract/index.js b/ui/app/components/pages/confirm-deploy-contract/index.js new file mode 100644 index 000000000..c4fb01b52 --- /dev/null +++ b/ui/app/components/pages/confirm-deploy-contract/index.js @@ -0,0 +1 @@ +export { default } from './confirm-deploy-contract.container' diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js new file mode 100644 index 000000000..442a478b8 --- /dev/null +++ b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' +import { SEND_ROUTE } from '../../../routes' + +export default class ConfirmSendEther extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + editTransaction: PropTypes.func, + history: PropTypes.object, + txParams: PropTypes.object, + } + + handleEdit ({ txData }) { + const { editTransaction, history } = this.props + editTransaction(txData) + history.push(SEND_ROUTE) + } + + shouldHideData () { + const { txParams = {} } = this.props + return !txParams.data + } + + render () { + const hideData = this.shouldHideData() + + return ( + <ConfirmTransactionBase + action={this.context.t('confirm')} + hideData={hideData} + onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js new file mode 100644 index 000000000..e48ef54a8 --- /dev/null +++ b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js @@ -0,0 +1,45 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import { updateSend } from '../../../actions' +import { clearConfirmTransaction } from '../../../ducks/confirm-transaction.duck' +import ConfirmSendEther from './confirm-send-ether.component' + +const mapStateToProps = state => { + const { confirmTransaction: { txData: { txParams } = {} } } = state + + return { + txParams, + } +} + +const mapDispatchToProps = dispatch => { + return { + editTransaction: txData => { + const { id, txParams } = txData + const { + gas: gasLimit, + gasPrice, + to, + value: amount, + } = txParams + + dispatch(updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount, + errors: { to: null, amount: null }, + editingTransactionId: id && id.toString(), + })) + + dispatch(clearConfirmTransaction()) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmSendEther) diff --git a/ui/app/components/pages/confirm-send-ether/index.js b/ui/app/components/pages/confirm-send-ether/index.js new file mode 100644 index 000000000..2d5767c39 --- /dev/null +++ b/ui/app/components/pages/confirm-send-ether/index.js @@ -0,0 +1 @@ +export { default } from './confirm-send-ether.container' diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js b/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js new file mode 100644 index 000000000..46ad9ccab --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' +import { SEND_ROUTE } from '../../../routes' + +export default class ConfirmSendToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + tokenAddress: PropTypes.string, + toAddress: PropTypes.string, + numberOfTokens: PropTypes.number, + tokenSymbol: PropTypes.string, + editTransaction: PropTypes.func, + } + + handleEdit (confirmTransactionData) { + const { editTransaction, history } = this.props + editTransaction(confirmTransactionData) + history.push(SEND_ROUTE) + } + + render () { + const { toAddress, tokenAddress, tokenSymbol, numberOfTokens } = this.props + + return ( + <ConfirmTransactionBase + toAddress={toAddress} + identiconAddress={tokenAddress} + title={`${numberOfTokens} ${tokenSymbol}`} + onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} + hideSubtitle + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js b/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js new file mode 100644 index 000000000..2d7efeed6 --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js @@ -0,0 +1,72 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import ConfirmSendToken from './confirm-send-token.component' +import { calcTokenAmount } from '../../../token-util' +import { clearConfirmTransaction } from '../../../ducks/confirm-transaction.duck' +import { setSelectedToken, updateSend, showSendTokenPage } from '../../../actions' +import { conversionUtil } from '../../../conversion-util' + +const mapStateToProps = state => { + const { confirmTransaction } = state + const { + tokenData = {}, + tokenProps: { tokenSymbol, tokenDecimals } = {}, + txData: { txParams: { to: tokenAddress } = {} } = {}, + } = confirmTransaction + const { params = [] } = tokenData + + let toAddress = '' + let tokenAmount = '' + + if (params && params.length === 2) { + [{ value: toAddress }, { value: tokenAmount }] = params + } + + const numberOfTokens = tokenAmount && tokenDecimals + ? calcTokenAmount(tokenAmount, tokenDecimals) + : 0 + + return { + toAddress, + tokenAddress, + tokenSymbol, + numberOfTokens, + } +} + +const mapDispatchToProps = dispatch => { + return { + editTransaction: ({ txData, tokenData, tokenProps }) => { + const { txParams: { to: tokenAddress, gas: gasLimit, gasPrice } = {}, id } = txData + const { params = [] } = tokenData + const { value: to } = params[0] || {} + const { value: tokenAmountInDec } = params[1] || {} + const tokenAmountInHex = conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) + dispatch(setSelectedToken(tokenAddress)) + dispatch(updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount: tokenAmountInHex, + errors: { to: null, amount: null }, + editingTransactionId: id && id.toString(), + token: { + ...tokenProps, + address: tokenAddress, + }, + })) + dispatch(clearConfirmTransaction()) + dispatch(showSendTokenPage()) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmSendToken) diff --git a/ui/app/components/pages/confirm-send-token/index.js b/ui/app/components/pages/confirm-send-token/index.js new file mode 100644 index 000000000..409b6ef3d --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/index.js @@ -0,0 +1 @@ +export { default } from './confirm-send-token.container' diff --git a/ui/app/components/pages/confirm-send-token/index.scss b/ui/app/components/pages/confirm-send-token/index.scss new file mode 100644 index 000000000..0476749f6 --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/index.scss @@ -0,0 +1,19 @@ +.confirm-send-token { + &__title { + padding: 4px 0; + display: flex; + align-items: center; + } + + &__identicon { + flex: 0 0 auto; + } + + &__title-text { + font-size: 2.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 8px; + } +} 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 new file mode 100644 index 000000000..842b34d2e --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -0,0 +1,320 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmPageContainer, { ConfirmDetailRow } from '../../confirm-page-container' +import { formatCurrency } from '../../../helpers/confirm-transaction/util' +import { isBalanceSufficient } from '../../send_/send.utils' +import { DEFAULT_ROUTE } from '../../../routes' +import { + INSUFFICIENT_FUNDS_ERROR_KEY, + TRANSACTION_ERROR_KEY, +} from '../../../constants/error-keys' + +export default class ConfirmTransactionBase extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + // react-router props + match: PropTypes.object, + history: PropTypes.object, + // Redux props + balance: PropTypes.string, + cancelTransaction: PropTypes.func, + clearConfirmTransaction: PropTypes.func, + clearSend: PropTypes.func, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + editTransaction: PropTypes.func, + ethTransactionAmount: PropTypes.string, + ethTransactionFee: PropTypes.string, + ethTransactionTotal: PropTypes.string, + fiatTransactionAmount: PropTypes.string, + fiatTransactionFee: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + fromAddress: PropTypes.string, + fromName: PropTypes.string, + hexGasTotal: PropTypes.string, + isTxReprice: PropTypes.bool, + methodData: PropTypes.object, + nonce: PropTypes.string, + sendTransaction: PropTypes.func, + showCustomizeGasModal: PropTypes.func, + showTransactionConfirmedModal: PropTypes.func, + toAddress: PropTypes.string, + tokenData: PropTypes.object, + tokenProps: PropTypes.object, + toName: PropTypes.string, + transactionStatus: PropTypes.string, + txData: PropTypes.object, + // Component props + action: PropTypes.string, + contentComponent: PropTypes.node, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + hideData: PropTypes.bool, + hideDetails: PropTypes.bool, + hideSubtitle: PropTypes.bool, + identiconAddress: PropTypes.string, + onCancel: PropTypes.func, + onEdit: PropTypes.func, + onEditGas: PropTypes.func, + onSubmit: PropTypes.func, + subtitle: PropTypes.string, + summaryComponent: PropTypes.node, + title: PropTypes.string, + valid: PropTypes.bool, + warning: PropTypes.string, + } + + componentDidUpdate () { + const { + transactionStatus, + showTransactionConfirmedModal, + history, + clearConfirmTransaction, + } = this.props + + if (transactionStatus === 'dropped') { + showTransactionConfirmedModal({ + onHide: () => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + + return + } + } + + getErrorKey () { + const { + balance, + conversionRate, + hexGasTotal, + txData: { + simulationFails, + txParams: { + value: amount, + } = {}, + } = {}, + } = this.props + + const insufficientBalance = balance && !isBalanceSufficient({ + amount, + gasTotal: hexGasTotal || '0x0', + balance, + conversionRate, + }) + + if (insufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + if (simulationFails) { + return { + valid: false, + errorKey: TRANSACTION_ERROR_KEY, + } + } + + return { + valid: true, + } + } + + handleEditGas () { + const { onEditGas, showCustomizeGasModal } = this.props + + if (onEditGas) { + onEditGas() + } else { + showCustomizeGasModal() + } + } + + renderDetails () { + const { + detailsComponent, + fiatTransactionFee, + ethTransactionFee, + currentCurrency, + fiatTransactionTotal, + ethTransactionTotal, + hideDetails, + } = this.props + + if (hideDetails) { + return null + } + + return ( + detailsComponent || ( + <div className="confirm-page-container-content__details"> + <div className="confirm-page-container-content__gas-fee"> + <ConfirmDetailRow + label="Gas Fee" + fiatFee={formatCurrency(fiatTransactionFee, currentCurrency)} + ethFee={ethTransactionFee} + headerText="Edit" + headerTextClassName="confirm-detail-row__header-text--edit" + onHeaderClick={() => this.handleEditGas()} + /> + </div> + <div> + <ConfirmDetailRow + label="Total" + fiatFee={formatCurrency(fiatTransactionTotal, currentCurrency)} + ethFee={ethTransactionTotal} + headerText="Amount + Gas Fee" + headerTextClassName="confirm-detail-row__header-text--total" + fiatFeeColor="#2f9ae0" + /> + </div> + </div> + ) + ) + } + + renderData () { + const { t } = this.context + const { + txData: { + txParams: { + data, + } = {}, + } = {}, + methodData: { + name, + params, + } = {}, + hideData, + dataComponent, + } = this.props + + if (hideData) { + return null + } + + return dataComponent || ( + <div className="confirm-page-container-content__data"> + <div className="confirm-page-container-content__data-box-label"> + {`${t('functionType')}:`} + <span className="confirm-page-container-content__function-type"> + { name } + </span> + </div> + <div className="confirm-page-container-content__data-box"> + <div className="confirm-page-container-content__data-field-label"> + { `${t('parameters')}:` } + </div> + <div> + <pre>{ JSON.stringify(params, null, 2) }</pre> + </div> + </div> + <div className="confirm-page-container-content__data-box-label"> + {`${t('hexData')}:`} + </div> + <div className="confirm-page-container-content__data-box"> + { data } + </div> + </div> + ) + } + + handleEdit () { + const { txData, tokenData, tokenProps, onEdit } = this.props + onEdit({ txData, tokenData, tokenProps }) + } + + handleCancel () { + const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction } = this.props + + if (onCancel) { + onCancel(txData) + } else { + cancelTransaction(txData) + .then(() => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }) + } + } + + handleSubmit () { + const { sendTransaction, clearConfirmTransaction, txData, history, onSubmit } = this.props + + if (onSubmit) { + onSubmit(txData) + } else { + sendTransaction(txData) + .then(() => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }) + } + } + + render () { + const { + isTxReprice, + fromName, + fromAddress, + toName, + toAddress, + methodData, + ethTransactionAmount, + fiatTransactionAmount, + valid: propsValid, + errorMessage, + errorKey: propsErrorKey, + currentCurrency, + action, + title, + subtitle, + hideSubtitle, + identiconAddress, + summaryComponent, + contentComponent, + onEdit, + nonce, + warning, + } = this.props + + const { name } = methodData + const fiatConvertedAmount = formatCurrency(fiatTransactionAmount, currentCurrency) + const { valid, errorKey } = this.getErrorKey() + + return ( + <ConfirmPageContainer + fromName={fromName} + fromAddress={fromAddress} + toName={toName} + toAddress={toAddress} + showEdit={onEdit && !isTxReprice} + action={action || name} + title={title || `${fiatConvertedAmount} ${currentCurrency.toUpperCase()}`} + subtitle={subtitle || `\u2666 ${ethTransactionAmount}`} + hideSubtitle={hideSubtitle} + summaryComponent={summaryComponent} + detailsComponent={this.renderDetails()} + dataComponent={this.renderData()} + contentComponent={contentComponent} + nonce={nonce} + identiconAddress={identiconAddress} + errorMessage={errorMessage} + errorKey={propsErrorKey || errorKey} + warning={warning} + valid={propsValid || valid} + onEdit={() => this.handleEdit()} + onCancel={() => this.handleCancel()} + onSubmit={() => this.handleSubmit()} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js new file mode 100644 index 000000000..31108bbd0 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -0,0 +1,169 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import R from 'ramda' +import ConfirmTransactionBase from './confirm-transaction-base.component' +import { + clearConfirmTransaction, + updateGasAndCalculate, +} from '../../../ducks/confirm-transaction.duck' +import { clearSend, cancelTx, updateAndApproveTx, showModal } from '../../../actions' +import { + INSUFFICIENT_FUNDS_ERROR_KEY, + GAS_LIMIT_TOO_LOW_ERROR_KEY, +} from '../../../constants/error-keys' +import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' +import { isBalanceSufficient } from '../../send_/send.utils' +import { conversionGreaterThan } from '../../../conversion-util' +import { MIN_GAS_LIMIT_DEC } from '../../send_/send.constants' + +const mapStateToProps = (state, props) => { + const { toAddress: propsToAddress } = props + const { confirmTransaction, metamask } = state + const { + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexGasTotal, + tokenData, + methodData, + txData, + tokenProps, + nonce, + } = confirmTransaction + const { txParams = {}, lastGasPrice, id: transactionId } = txData + const { from: fromAddress, to: txParamsToAddress } = txParams + const { + conversionRate, + identities, + currentCurrency, + accounts, + selectedAddress, + selectedAddressTxList, + } = metamask + + const { balance } = accounts[selectedAddress] + const { name: fromName } = identities[selectedAddress] + const toAddress = propsToAddress || txParamsToAddress + const toName = identities[toAddress] && identities[toAddress].name + const isTxReprice = Boolean(lastGasPrice) + + const transaction = R.find(({ id }) => id === transactionId)(selectedAddressTxList) + const transactionStatus = transaction ? transaction.status : '' + + return { + balance, + fromAddress, + fromName, + toAddress, + toName, + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexGasTotal, + txData, + tokenData, + methodData, + tokenProps, + isTxReprice, + currentCurrency, + conversionRate, + transactionStatus, + nonce, + } +} + +const mapDispatchToProps = dispatch => { + return { + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + clearSend: () => dispatch(clearSend()), + showTransactionConfirmedModal: ({ onHide }) => { + return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onHide })) + }, + showCustomizeGasModal: ({ txData, onSubmit, validate }) => { + return dispatch(showModal({ name: 'CONFIRM_CUSTOMIZE_GAS', txData, onSubmit, validate })) + }, + updateGasAndCalculate: ({ gasLimit, gasPrice }) => { + return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) + }, + cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), + sendTransaction: txData => dispatch(updateAndApproveTx(txData)), + } +} + +const getValidateEditGas = ({ balance, conversionRate, txData }) => { + const { txParams: { value: amount } = {} } = txData + + return ({ gasLimit, gasPrice }) => { + const gasTotal = getHexGasTotal({ gasLimit, gasPrice }) + const hasSufficientBalance = isBalanceSufficient({ + amount, + gasTotal, + balance, + conversionRate, + }) + + if (!hasSufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + const gasLimitTooLow = gasLimit && conversionGreaterThan( + { + value: MIN_GAS_LIMIT_DEC, + fromNumericBase: 'dec', + conversionRate, + }, + { + value: gasLimit, + fromNumericBase: 'hex', + }, + ) + + if (gasLimitTooLow) { + return { + valid: false, + errorKey: GAS_LIMIT_TOO_LOW_ERROR_KEY, + } + } + + return { + valid: true, + } + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { balance, conversionRate, txData } = stateProps + const { + showCustomizeGasModal: dispatchShowCustomizeGasModal, + updateGasAndCalculate: dispatchUpdateGasAndCalculate, + ...otherDispatchProps + } = dispatchProps + + const validateEditGas = getValidateEditGas({ balance, conversionRate, txData }) + + return { + ...stateProps, + ...otherDispatchProps, + ...ownProps, + showCustomizeGasModal: () => dispatchShowCustomizeGasModal({ + txData, + onSubmit: txData => dispatchUpdateGasAndCalculate(txData), + validate: validateEditGas, + }), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(ConfirmTransactionBase) diff --git a/ui/app/components/pages/confirm-transaction-base/index.js b/ui/app/components/pages/confirm-transaction-base/index.js new file mode 100644 index 000000000..9996e9aeb --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/index.js @@ -0,0 +1 @@ +export { default } from './confirm-transaction-base.container' diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js new file mode 100644 index 000000000..25259b98c --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js @@ -0,0 +1,77 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Redirect } from 'react-router-dom' +import Loading from '../../loading-screen' +import { + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, +} from '../../../routes' +import { isConfirmDeployContract } from './confirm-transaction-switch.util' +import { TOKEN_METHOD_TRANSFER, TOKEN_METHOD_APPROVE } from './confirm-transaction-switch.constants' + +export default class ConfirmTransactionSwitch extends Component { + static propTypes = { + txData: PropTypes.object, + methodData: PropTypes.object, + fetchingMethodData: PropTypes.bool, + } + + redirectToTransaction () { + const { + txData, + methodData: { name }, + fetchingMethodData, + } = this.props + const { id } = txData + + + if (isConfirmDeployContract(txData)) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_DEPLOY_CONTRACT_PATH}` + return <Redirect to={{ pathname }} /> + } + + if (fetchingMethodData) { + return <Loading /> + } + + if (name) { + const methodName = name.toLowerCase() + + switch (methodName.toLowerCase()) { + case TOKEN_METHOD_TRANSFER: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_TOKEN_PATH}` + return <Redirect to={{ pathname }} /> + } + case TOKEN_METHOD_APPROVE: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_APPROVE_PATH}` + return <Redirect to={{ pathname }} /> + } + default: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TOKEN_METHOD_PATH}` + return <Redirect to={{ pathname }} /> + } + } + } + + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` + return <Redirect to={{ pathname }} /> + } + + render () { + const { txData } = this.props + + if (txData.txParams) { + return this.redirectToTransaction() + } else if (txData.msgParams) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${txData.id}${SIGNATURE_REQUEST_PATH}` + return <Redirect to={{ pathname }} /> + } + + return <Loading /> + } +} diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js new file mode 100644 index 000000000..622d2a37a --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js @@ -0,0 +1,2 @@ +export const TOKEN_METHOD_TRANSFER = 'transfer' +export const TOKEN_METHOD_APPROVE = 'approve' diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js new file mode 100644 index 000000000..3d7fc78cc --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import ConfirmTransactionSwitch from './confirm-transaction-switch.component' + +const mapStateToProps = state => { + const { + confirmTransaction: { + txData, + methodData, + fetchingMethodData, + }, + } = state + + return { + txData, + methodData, + fetchingMethodData, + } +} + +export default connect(mapStateToProps)(ConfirmTransactionSwitch) diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js new file mode 100644 index 000000000..536aa5212 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js @@ -0,0 +1,4 @@ +export function isConfirmDeployContract (txData = {}) { + const { txParams = {} } = txData + return !txParams.to +} diff --git a/ui/app/components/pages/confirm-transaction-switch/index.js b/ui/app/components/pages/confirm-transaction-switch/index.js new file mode 100644 index 000000000..c288acb1a --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/index.js @@ -0,0 +1,2 @@ +import ConfirmTransactionSwitch from './confirm-transaction-switch.container' +module.exports = ConfirmTransactionSwitch diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js new file mode 100644 index 000000000..874a89fd2 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js @@ -0,0 +1,150 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route } from 'react-router-dom' +import Loading from '../../loading-screen' +import ConfirmTransactionSwitch from '../confirm-transaction-switch' +import ConfirmTransactionBase from '../confirm-transaction-base' +import ConfirmSendEther from '../confirm-send-ether' +import ConfirmSendToken from '../confirm-send-token' +import ConfirmDeployContract from '../confirm-deploy-contract' +import ConfirmApprove from '../confirm-approve' +import ConfTx from '../../../conf-tx' +import { + DEFAULT_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, +} from '../../../routes' + +export default class ConfirmTransaction extends Component { + static propTypes = { + history: PropTypes.object.isRequired, + totalUnapprovedCount: PropTypes.number.isRequired, + match: PropTypes.object, + send: PropTypes.object, + unconfirmedTransactions: PropTypes.array, + setTransactionToConfirm: PropTypes.func, + confirmTransaction: PropTypes.object, + clearConfirmTransaction: PropTypes.func, + } + + getParamsTransactionId () { + const { match: { params: { id } = {} } } = this.props + return id || null + } + + componentDidMount () { + const { + totalUnapprovedCount = 0, + send = {}, + history, + confirmTransaction: { txData: { id: transactionId } = {} }, + } = this.props + + if (!totalUnapprovedCount && !send.to) { + history.replace(DEFAULT_ROUTE) + return + } + + if (!transactionId) { + this.setTransactionToConfirm() + } + } + + componentDidUpdate () { + const { + setTransactionToConfirm, + confirmTransaction: { txData: { id: transactionId } = {} }, + clearConfirmTransaction, + } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + if (paramsTransactionId && transactionId && paramsTransactionId !== transactionId + '') { + clearConfirmTransaction() + setTransactionToConfirm(paramsTransactionId) + return + } + + if (!transactionId) { + this.setTransactionToConfirm() + } + } + + setTransactionToConfirm () { + const { + history, + unconfirmedTransactions, + setTransactionToConfirm, + } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + if (paramsTransactionId) { + // Check to make sure params ID is valid + const tx = unconfirmedTransactions.find(({ id }) => id + '' === paramsTransactionId) + + if (!tx) { + history.replace(DEFAULT_ROUTE) + } else { + setTransactionToConfirm(paramsTransactionId) + } + } else if (unconfirmedTransactions.length) { + const totalUnconfirmed = unconfirmedTransactions.length + const transaction = unconfirmedTransactions[totalUnconfirmed - 1] + const { id: transactionId, loadingDefaults } = transaction + + if (!loadingDefaults) { + setTransactionToConfirm(transactionId) + } + } + } + + render () { + const { confirmTransaction: { txData: { id } } = {} } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + // Show routes when state.confirmTransaction has been set and when either the ID in the params + // isn't specified or is specified and matches the ID in state.confirmTransaction in order to + // support URLs of /confirm-transaction or /confirm-transaction/<transactionId> + return id && (!paramsTransactionId || paramsTransactionId === id + '') + ? ( + <Switch> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_DEPLOY_CONTRACT_PATH}`} + component={ConfirmDeployContract} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TOKEN_METHOD_PATH}`} + component={ConfirmTransactionBase} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_ETHER_PATH}`} + component={ConfirmSendEther} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_TOKEN_PATH}`} + component={ConfirmSendToken} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_APPROVE_PATH}`} + component={ConfirmApprove} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${SIGNATURE_REQUEST_PATH}`} + component={ConfTx} + /> + <Route path="*" component={ConfirmTransactionSwitch} /> + </Switch> + ) + : <Loading /> + } +} diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js new file mode 100644 index 000000000..1bc2f1efb --- /dev/null +++ b/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js @@ -0,0 +1,33 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import { + setTransactionToConfirm, + clearConfirmTransaction, +} from '../../../ducks/confirm-transaction.duck' +import ConfirmTransaction from './confirm-transaction.component' +import { getTotalUnapprovedCount } from '../../../selectors' +import { unconfirmedTransactionsListSelector } from '../../../selectors/confirm-transaction' + +const mapStateToProps = state => { + const { metamask: { send }, confirmTransaction } = state + + return { + totalUnapprovedCount: getTotalUnapprovedCount(state), + send, + confirmTransaction, + unconfirmedTransactions: unconfirmedTransactionsListSelector(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + setTransactionToConfirm: transactionId => dispatch(setTransactionToConfirm(transactionId)), + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps), +)(ConfirmTransaction) diff --git a/ui/app/components/pages/confirm-transaction/index.js b/ui/app/components/pages/confirm-transaction/index.js new file mode 100644 index 000000000..4bf42d85c --- /dev/null +++ b/ui/app/components/pages/confirm-transaction/index.js @@ -0,0 +1,2 @@ +import ConfirmTransaction from './confirm-transaction.container' +module.exports = ConfirmTransaction diff --git a/ui/app/components/pages/home.js b/ui/app/components/pages/home.js index c53413d3b..86bd32c8a 100644 --- a/ui/app/components/pages/home.js +++ b/ui/app/components/pages/home.js @@ -83,51 +83,6 @@ class Home extends Component { }) } - // if (!props.noActiveNotices) { - // log.debug('rendering notice screen for unread notices.') - // return h(NoticeScreen, { - // notice: props.nextUnreadNotice, - // key: 'NoticeScreen', - // onConfirm: () => props.dispatch(actions.markNoticeRead(props.nextUnreadNotice)), - // }) - // } else if (props.lostAccounts && props.lostAccounts.length > 0) { - // log.debug('rendering notice screen for lost accounts view.') - // return h(NoticeScreen, { - // notice: generateLostAccountsNotice(props.lostAccounts), - // key: 'LostAccountsNotice', - // onConfirm: () => props.dispatch(actions.markAccountsFound()), - // }) - // } - - // if (props.seedWords) { - // log.debug('rendering seed words') - // return h(HDCreateVaultComplete, {key: 'HDCreateVaultComplete'}) - // } - - // show initialize screen - // if (!isInitialized || forgottenPassword) { - // // show current view - // log.debug('rendering an initialize screen') - // // switch (props.currentView.name) { - - // // case 'restoreVault': - // // log.debug('rendering restore vault screen') - // // return h(HDRestoreVaultScreen, {key: 'HDRestoreVaultScreen'}) - - // // default: - // // log.debug('rendering menu screen') - // // return h(InitializeScreen, {key: 'menuScreenInit'}) - // // } - // } - - // // show unlock screen - // if (!props.isUnlocked) { - // return h(MainContainer, { - // currentViewName: props.currentView.name, - // isUnlocked: props.isUnlocked, - // }) - // } - // show current view switch (currentView.name) { @@ -135,59 +90,10 @@ class Home extends Component { log.debug('rendering main container') return h(MainContainer, {key: 'account-detail'}) - // case 'sendTransaction': - // log.debug('rendering send tx screen') - - // // Going to leave this here until we are ready to delete SendTransactionScreen v1 - // // const SendComponentToRender = checkFeatureToggle('send-v2') - // // ? SendTransactionScreen2 - // // : SendTransactionScreen - - // return h(SendTransactionScreen2, {key: 'send-transaction'}) - - // case 'sendToken': - // log.debug('rendering send token screen') - - // // Going to leave this here until we are ready to delete SendTransactionScreen v1 - // // const SendTokenComponentToRender = checkFeatureToggle('send-v2') - // // ? SendTransactionScreen2 - // // : SendTokenScreen - - // return h(SendTransactionScreen2, {key: 'sendToken'}) - case 'newKeychain': log.debug('rendering new keychain screen') return h(NewKeyChainScreen, {key: 'new-keychain'}) - // case 'confTx': - // log.debug('rendering confirm tx screen') - // return h(Redirect, { - // to: { - // pathname: CONFIRM_TRANSACTION_ROUTE, - // }, - // }) - // return h(ConfirmTxScreen, {key: 'confirm-tx'}) - - // case 'add-token': - // log.debug('rendering add-token screen from unlock screen.') - // return h(AddTokenScreen, {key: 'add-token'}) - - // case 'config': - // log.debug('rendering config screen') - // return h(Settings, {key: 'config'}) - - // case 'import-menu': - // log.debug('rendering import screen') - // return h(Import, {key: 'import-menu'}) - - // case 'reveal-seed-conf': - // log.debug('rendering reveal seed confirmation screen') - // return h(RevealSeedConfirmation, {key: 'reveal-seed-conf'}) - - // case 'info': - // log.debug('rendering info screen') - // return h(Settings, {key: 'info', tab: 'info'}) - case 'buyEth': log.debug('rendering buy ether screen') return h(BuyView, {key: 'buyEthView'}) diff --git a/ui/app/components/pages/index.scss b/ui/app/components/pages/index.scss index b15c59863..8b333b6a8 100644 --- a/ui/app/components/pages/index.scss +++ b/ui/app/components/pages/index.scss @@ -3,3 +3,5 @@ @import './add-token/index'; @import './confirm-add-token/index'; + +@import './confirm-send-token/index'; diff --git a/ui/app/components/pages/unlock-page/unlock-page.component.js b/ui/app/components/pages/unlock-page/unlock-page.component.js index a1d3f9181..94915df76 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ b/ui/app/components/pages/unlock-page/unlock-page.component.js @@ -2,19 +2,27 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import Button from '@material-ui/core/Button' import TextField from '../../text-field' - -const { ENVIRONMENT_TYPE_POPUP } = require('../../../../../app/scripts/lib/enums') -const { getEnvironmentType } = require('../../../../../app/scripts/lib/util') -const getCaretCoordinates = require('textarea-caret') -const EventEmitter = require('events').EventEmitter -const Mascot = require('../../mascot') -const { DEFAULT_ROUTE, RESTORE_VAULT_ROUTE } = require('../../../routes') - -class UnlockPage extends Component { +import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../../app/scripts/lib/util' +import getCaretCoordinates from 'textarea-caret' +import { EventEmitter } from 'events' +import Mascot from '../../mascot' +import { DEFAULT_ROUTE, RESTORE_VAULT_ROUTE } from '../../../routes' + +export default class UnlockPage extends Component { static contextTypes = { t: PropTypes.func, } + static propTypes = { + forgotPassword: PropTypes.func, + tryUnlockMetamask: PropTypes.func, + markPasswordForgotten: PropTypes.func, + history: PropTypes.object, + isUnlocked: PropTypes.bool, + useOldInterface: PropTypes.func, + } + constructor (props) { super(props) @@ -23,6 +31,7 @@ class UnlockPage extends Component { error: null, } + this.submitting = false this.animationEventEmitter = new EventEmitter() } @@ -41,20 +50,21 @@ class UnlockPage extends Component { const { password } = this.state const { tryUnlockMetamask, history } = this.props - if (password === '') { + if (password === '' || this.submitting) { return } this.setState({ error: null }) + this.submitting = true try { await tryUnlockMetamask(password) + this.submitting = false + history.push(DEFAULT_ROUTE) } catch ({ message }) { this.setState({ error: message }) - return + this.submitting = false } - - history.push(DEFAULT_ROUTE) } handleInputChange ({ target }) { @@ -98,7 +108,9 @@ class UnlockPage extends Component { } render () { - const { error } = this.state + const { password, error } = this.state + const { t } = this.context + const { markPasswordForgotten, history } = this.props return ( <div className="unlock-page__container"> @@ -111,18 +123,18 @@ class UnlockPage extends Component { /> </div> <h1 className="unlock-page__title"> - { this.context.t('welcomeBack') } + { t('welcomeBack') } </h1> - <div>{ this.context.t('unlockMessage') }</div> + <div>{ t('unlockMessage') }</div> <form className="unlock-page__form" onSubmit={event => this.handleSubmit(event)} > <TextField id="password" - label={this.context.t('password')} + label={t('password')} type="password" - value={this.state.password} + value={password} onChange={event => this.handleInputChange(event)} error={error} autoFocus @@ -136,28 +148,28 @@ class UnlockPage extends Component { <div className="unlock-page__link" onClick={() => { - this.props.markPasswordForgotten() - this.props.history.push(RESTORE_VAULT_ROUTE) + markPasswordForgotten() + history.push(RESTORE_VAULT_ROUTE) if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { global.platform.openExtensionInBrowser() } }} > - { this.context.t('restoreFromSeed') } + { t('restoreFromSeed') } </div> <div className="unlock-page__link unlock-page__link--import" onClick={() => { - this.props.markPasswordForgotten() - this.props.history.push(RESTORE_VAULT_ROUTE) + markPasswordForgotten() + history.push(RESTORE_VAULT_ROUTE) if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { global.platform.openExtensionInBrowser() } }} > - { this.context.t('importUsingSeed') } + { t('importUsingSeed') } </div> </div> </div> @@ -165,15 +177,3 @@ class UnlockPage extends Component { ) } } - -UnlockPage.propTypes = { - forgotPassword: PropTypes.func, - tryUnlockMetamask: PropTypes.func, - markPasswordForgotten: PropTypes.func, - history: PropTypes.object, - isUnlocked: PropTypes.bool, - t: PropTypes.func, - useOldInterface: PropTypes.func, -} - -export default UnlockPage diff --git a/ui/app/components/send_/send-footer/send-footer.component.js b/ui/app/components/send_/send-footer/send-footer.component.js index 6471ae1a3..ff42e58de 100644 --- a/ui/app/components/send_/send-footer/send-footer.component.js +++ b/ui/app/components/send_/send-footer/send-footer.component.js @@ -48,6 +48,7 @@ export default class SendFooter extends Component { // updateTx, update, toAccounts, + history, } = this.props // Should not be needed because submit should be disabled if there are errors. @@ -60,7 +61,7 @@ export default class SendFooter extends Component { // TODO: add nickname functionality addToAddressBookIfNew(to, toAccounts) - editingTransactionId + const promise = editingTransactionId ? update({ amount, editingTransactionId, @@ -73,7 +74,8 @@ export default class SendFooter extends Component { }) : sign({ selectedToken, to, amount, from, gas, gasPrice }) - this.props.history.push(CONFIRM_TRANSACTION_ROUTE) + Promise.resolve(promise) + .then(() => history.push(CONFIRM_TRANSACTION_ROUTE)) } formShouldBeDisabled () { diff --git a/ui/app/components/send_/send-footer/send-footer.container.js b/ui/app/components/send_/send-footer/send-footer.container.js index 260ff40bc..0af6fcfa1 100644 --- a/ui/app/components/send_/send-footer/send-footer.container.js +++ b/ui/app/components/send_/send-footer/send-footer.container.js @@ -87,7 +87,7 @@ function mapDispatchToProps (dispatch) { unapprovedTxs, }) - dispatch(updateTransaction(editingTx)) + return dispatch(updateTransaction(editingTx)) }, addToAddressBookIfNew: (newAddress, toAccounts, nickname = '') => { const hexPrefixedAddress = ethUtil.addHexPrefix(newAddress) diff --git a/ui/app/components/send_/send-footer/tests/send-footer-component.test.js b/ui/app/components/send_/send-footer/tests/send-footer-component.test.js index e071fe54f..4b2cd327d 100644 --- a/ui/app/components/send_/send-footer/tests/send-footer-component.test.js +++ b/ui/app/components/send_/send-footer/tests/send-footer-component.test.js @@ -166,10 +166,13 @@ describe('SendFooter Component', function () { assert.equal(propsMethodSpies.update.callCount, 0) }) - it('should call history.push', () => { - wrapper.instance().onSubmit(MOCK_EVENT) - assert.equal(historySpies.push.callCount, 1) - assert.equal(historySpies.push.getCall(0).args[0], CONFIRM_TRANSACTION_ROUTE) + it('should call history.push', done => { + Promise.resolve(wrapper.instance().onSubmit(MOCK_EVENT)) + .then(() => { + assert.equal(historySpies.push.callCount, 1) + assert.equal(historySpies.push.getCall(0).args[0], CONFIRM_TRANSACTION_ROUTE) + done() + }) }) }) diff --git a/ui/app/components/send_/send.utils.js b/ui/app/components/send_/send.utils.js index c4537f335..aa255c3d4 100644 --- a/ui/app/components/send_/send.utils.js +++ b/ui/app/components/send_/send.utils.js @@ -37,7 +37,7 @@ module.exports = { removeLeadingZeroes, } -function calcGasTotal (gasLimit, gasPrice) { +function calcGasTotal (gasLimit = '0', gasPrice = '0') { return multiplyCurrencies(gasLimit, gasPrice, { toNumericBase: 'hex', multiplicandBase: 16, @@ -47,9 +47,9 @@ function calcGasTotal (gasLimit, gasPrice) { function isBalanceSufficient ({ amount = '0x0', - amountConversionRate = 0, - balance, - conversionRate, + amountConversionRate = 1, + balance = '0x0', + conversionRate = 1, gasTotal = '0x0', primaryCurrency, }) { diff --git a/ui/app/components/sender-to-recipient.js b/ui/app/components/sender-to-recipient.js deleted file mode 100644 index 9cef8e401..000000000 --- a/ui/app/components/sender-to-recipient.js +++ /dev/null @@ -1,72 +0,0 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const PropTypes = require('prop-types') -const Identicon = require('./identicon') - -class SenderToRecipient extends Component { - renderRecipientIcon () { - const { recipientAddress } = this.props - return ( - recipientAddress - ? h(Identicon, { address: recipientAddress, diameter: 20 }) - : h('i.fa.fa-file-text-o') - ) - } - - renderRecipient () { - const { recipientName } = this.props - return ( - h('.sender-to-recipient__recipient', [ - this.renderRecipientIcon(), - h( - '.sender-to-recipient__name.sender-to-recipient__recipient-name', - recipientName || this.context.t('newContract') - ), - ]) - ) - } - - render () { - const { senderName, senderAddress } = this.props - - return ( - h('.sender-to-recipient__container', [ - h('.sender-to-recipient__sender', [ - h('.sender-to-recipient__sender-icon', [ - h(Identicon, { - address: senderAddress, - diameter: 20, - }), - ]), - h('.sender-to-recipient__name.sender-to-recipient__sender-name', senderName), - ]), - h('.sender-to-recipient__arrow-container', [ - h('.sender-to-recipient__arrow-circle', [ - h('img', { - height: 15, - width: 15, - src: './images/arrow-right.svg', - }), - ]), - ]), - this.renderRecipient(), - ]) - ) - } -} - -SenderToRecipient.propTypes = { - senderName: PropTypes.string, - senderAddress: PropTypes.string, - recipientName: PropTypes.string, - recipientAddress: PropTypes.string, - t: PropTypes.func, -} - -SenderToRecipient.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(SenderToRecipient) - diff --git a/ui/app/components/sender-to-recipient/index.js b/ui/app/components/sender-to-recipient/index.js new file mode 100644 index 000000000..f515c4ac4 --- /dev/null +++ b/ui/app/components/sender-to-recipient/index.js @@ -0,0 +1 @@ +export { default } from './sender-to-recipient.component' diff --git a/ui/app/css/itcss/components/sender-to-recipient.scss b/ui/app/components/sender-to-recipient/index.scss index f16013cdf..a97393b8f 100644 --- a/ui/app/css/itcss/components/sender-to-recipient.scss +++ b/ui/app/components/sender-to-recipient/index.scss @@ -6,6 +6,16 @@ justify-content: center; border-bottom: 1px solid $geyser; position: relative; + flex: 0 0 auto; + height: 42px; + } + + &__tooltip-wrapper { + min-width: 0; + } + + &__tooltip-container { + max-width: 100%; } &__sender, @@ -14,7 +24,7 @@ flex-direction: row; align-items: center; flex: 1; - padding: 10px 20px; + padding: 0 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -22,11 +32,16 @@ &__sender { padding-right: 30px; + cursor: pointer; } &__recipient { - border-left: 1px solid $geyser; padding-left: 30px; + border-left: 1px solid $geyser; + + &--with-address { + cursor: pointer; + } } &__arrow-container { @@ -42,17 +57,18 @@ padding: 5px; border: 1px solid $geyser; border-radius: 20px; - height: 30px; - width: 30px; + height: 32px; + width: 32px; display: flex; justify-content: center; align-items: center; } &__name { - padding-left: 5px; + padding-left: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: .875rem; } } 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 new file mode 100644 index 000000000..cae173b56 --- /dev/null +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js @@ -0,0 +1,117 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import Identicon from '../identicon' +import Tooltip from '../tooltip-v2' +import copyToClipboard from 'copy-to-clipboard' + +export default class SenderToRecipient extends Component { + static propTypes = { + senderName: PropTypes.string, + senderAddress: PropTypes.string, + recipientName: PropTypes.string, + recipientAddress: PropTypes.string, + t: PropTypes.func, + } + + static contextTypes = { + t: PropTypes.func, + } + + state = { + senderAddressCopied: false, + recipientAddressCopied: false, + } + + renderRecipientWithAddress () { + const { t } = this.context + const { recipientName, recipientAddress } = this.props + + return ( + <div + className="sender-to-recipient__recipient sender-to-recipient__recipient--with-address" + onClick={() => { + this.setState({ recipientAddressCopied: true }) + copyToClipboard(recipientAddress) + }} + > + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={recipientAddress} + diameter={24} + /> + </div> + <Tooltip + position="bottom" + title={this.state.recipientAddressCopied ? t('copiedExclamation') : t('copyAddress')} + wrapperClassName="sender-to-recipient__tooltip-wrapper" + containerClassName="sender-to-recipient__tooltip-container" + onHidden={() => this.setState({ recipientAddressCopied: false })} + > + <div className="sender-to-recipient__name sender-to-recipient__recipient-name"> + { recipientName || this.context.t('newContract') } + </div> + </Tooltip> + </div> + ) + } + + renderRecipientWithoutAddress () { + return ( + <div className="sender-to-recipient__recipient"> + <i className="fa fa-file-text-o" /> + <div className="sender-to-recipient__name sender-to-recipient__recipient-name"> + { this.context.t('newContract') } + </div> + </div> + ) + } + + render () { + const { t } = this.context + const { senderName, senderAddress, recipientAddress } = this.props + + return ( + <div className="sender-to-recipient__container"> + <div + className="sender-to-recipient__sender" + onClick={() => { + this.setState({ senderAddressCopied: true }) + copyToClipboard(senderAddress) + }} + > + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={senderAddress} + diameter={24} + /> + </div> + <Tooltip + position="bottom" + title={this.state.senderAddressCopied ? t('copiedExclamation') : t('copyAddress')} + wrapperClassName="sender-to-recipient__tooltip-wrapper" + containerClassName="sender-to-recipient__tooltip-container" + onHidden={() => this.setState({ senderAddressCopied: false })} + > + <div className="sender-to-recipient__name sender-to-recipient__sender-name"> + { senderName } + </div> + </Tooltip> + </div> + <div className="sender-to-recipient__arrow-container"> + <div className="sender-to-recipient__arrow-circle"> + <img + height={15} + width={15} + src="./images/arrow-right.svg" + /> + </div> + </div> + { + recipientAddress + ? this.renderRecipientWithAddress() + : this.renderRecipientWithoutAddress() + } + </div> + ) + } +} diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js index bbb340fcf..2e0102d1a 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/signature-request.js @@ -22,6 +22,8 @@ const { conversionRateSelector, } = require('../selectors.js') +import { clearConfirmTransaction } from '../ducks/confirm-transaction.duck' + const { DEFAULT_ROUTE } = require('../routes') function mapStateToProps (state) { @@ -39,6 +41,7 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return { goHome: () => dispatch(actions.goHome()), + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), } } @@ -247,12 +250,18 @@ SignatureRequest.prototype.renderFooter = function () { return h('div.request-signature__footer', [ h('button.btn-default.btn--large.request-signature__footer__cancel-button', { onClick: event => { - cancel(event).then(() => this.props.history.push(DEFAULT_ROUTE)) + cancel(event).then(() => { + this.props.clearConfirmTransaction() + this.props.history.push(DEFAULT_ROUTE) + }) }, }, this.context.t('cancel')), h('button.btn-primary.btn--large', { onClick: event => { - sign(event).then(() => this.props.history.push(DEFAULT_ROUTE)) + sign(event).then(() => { + this.props.clearConfirmTransaction() + this.props.history.push(DEFAULT_ROUTE) + }) }, }, this.context.t('sign')), ]) diff --git a/ui/app/components/tabs/index.js b/ui/app/components/tabs/index.js new file mode 100644 index 000000000..3a8d18248 --- /dev/null +++ b/ui/app/components/tabs/index.js @@ -0,0 +1,3 @@ +import Tabs from './tabs.component' +import Tab from './tab' +export { Tabs, Tab } diff --git a/ui/app/components/tabs/index.scss b/ui/app/components/tabs/index.scss new file mode 100644 index 000000000..a3b42f8e3 --- /dev/null +++ b/ui/app/components/tabs/index.scss @@ -0,0 +1,11 @@ +@import './tab/index'; + +.tabs { + &__list { + display: flex; + justify-content: flex-start; + background-color: #f9fafa; + border-bottom: 1px solid $geyser; + padding: 0 16px; + } +} diff --git a/ui/app/components/tabs/tab/index.js b/ui/app/components/tabs/tab/index.js new file mode 100644 index 000000000..fbc309e8e --- /dev/null +++ b/ui/app/components/tabs/tab/index.js @@ -0,0 +1,2 @@ +import Tab from './tab.component' +module.exports = Tab diff --git a/ui/app/components/tabs/tab/index.scss b/ui/app/components/tabs/tab/index.scss new file mode 100644 index 000000000..1de6ffa0e --- /dev/null +++ b/ui/app/components/tabs/tab/index.scss @@ -0,0 +1,15 @@ +.tab { + color: #8C8E94; + font-size: .75rem; + text-transform: uppercase; + cursor: pointer; + padding: 8px 0; + margin: 0 8px; + min-width: 50px; + text-align: center; + + &--active { + color: $black; + border-bottom: 2px solid $curious-blue; + } +} diff --git a/ui/app/components/tabs/tab/tab.component.js b/ui/app/components/tabs/tab/tab.component.js new file mode 100644 index 000000000..a59da8904 --- /dev/null +++ b/ui/app/components/tabs/tab/tab.component.js @@ -0,0 +1,31 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +const Tab = props => { + const { name, onClick, isActive, tabIndex } = props + + return ( + <li + className={classnames( + 'tab', + isActive && 'tab--active', + )} + onClick={event => { + event.preventDefault() + onClick(tabIndex) + }} + > + { name } + </li> + ) +} + +Tab.propTypes = { + name: PropTypes.string.isRequired, + onClick: PropTypes.func, + isActive: PropTypes.bool, + tabIndex: PropTypes.number, +} + +export default Tab diff --git a/ui/app/components/tabs/tabs.component.js b/ui/app/components/tabs/tabs.component.js new file mode 100644 index 000000000..d26dcff2f --- /dev/null +++ b/ui/app/components/tabs/tabs.component.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class Tabs extends Component { + static propTypes = { + defaultActiveTabIndex: PropTypes.number, + children: PropTypes.node, + } + + constructor (props) { + super(props) + + this.state = { + activeTabIndex: props.defaultActiveTabIndex || 0, + } + } + + handleTabClick (tabIndex) { + const { activeTabIndex } = this.state + + if (tabIndex !== activeTabIndex) { + this.setState({ + activeTabIndex: tabIndex, + }) + } + } + + renderTabs () { + const numberOfTabs = React.Children.count(this.props.children) + + return React.Children.map(this.props.children, (child, index) => { + return child && React.cloneElement(child, { + onClick: index => this.handleTabClick(index), + tabIndex: index, + isActive: numberOfTabs > 1 && index === this.state.activeTabIndex, + key: index, + }) + }) + } + + renderActiveTabContent () { + const { children } = this.props + const { activeTabIndex } = this.state + + return children[activeTabIndex] + ? children[activeTabIndex].props.children + : children.props.children + } + + render () { + return ( + <div className="tabs"> + <ul className="tabs__list"> + { this.renderTabs() } + </ul> + <div className="tabs__content"> + { this.renderActiveTabContent() } + </div> + </div> + ) + } +} diff --git a/ui/app/components/tooltip-v2.js b/ui/app/components/tooltip-v2.js index 133a0f16a..05a5efc80 100644 --- a/ui/app/components/tooltip-v2.js +++ b/ui/app/components/tooltip-v2.js @@ -12,7 +12,7 @@ function Tooltip () { Tooltip.prototype.render = function () { const props = this.props - const { position, title, children, wrapperClassName } = props + const { position, title, children, wrapperClassName, containerClassName, onHidden } = props return h('div', { className: wrapperClassName, @@ -25,6 +25,8 @@ Tooltip.prototype.render = function () { hideOnClick: false, size: 'small', arrow: true, + className: containerClassName, + onHidden, }, children), ]) diff --git a/ui/app/conf-tx.js b/ui/app/conf-tx.js index 461587cb1..4e8aaa07d 100644 --- a/ui/app/conf-tx.js +++ b/ui/app/conf-tx.js @@ -105,7 +105,7 @@ ConfirmTxScreen.prototype.componentDidUpdate = function (prevProps) { const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network) - if (prevTx.status === 'dropped') { + if (prevTx && prevTx.status === 'dropped') { this.props.dispatch(actions.showModal({ name: 'TRANSACTION_CONFIRMED', onHide: () => history.push(DEFAULT_ROUTE), @@ -174,7 +174,6 @@ ConfirmTxScreen.prototype.render = function () { ]), */ - return currentTxView({ // Properties txData: txData, diff --git a/ui/app/constants/error-keys.js b/ui/app/constants/error-keys.js new file mode 100644 index 000000000..1b89be62e --- /dev/null +++ b/ui/app/constants/error-keys.js @@ -0,0 +1,3 @@ +export const INSUFFICIENT_FUNDS_ERROR_KEY = 'insufficientFunds' +export const GAS_LIMIT_TOO_LOW_ERROR_KEY = 'gasLimitTooLow' +export const TRANSACTION_ERROR = 'transactionError' diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss index f93daec04..34565767f 100644 --- a/ui/app/css/itcss/components/buttons.scss +++ b/ui/app/css/itcss/components/buttons.scss @@ -4,7 +4,8 @@ .btn-default, .btn-primary, -.btn-secondary { +.btn-secondary, +.btn-confirm { height: 44px; background: $white; display: flex; @@ -13,13 +14,14 @@ box-sizing: border-box; border-radius: 4px; font-size: 14px; - font-weight: 500; + font-weight: 400; transition: border-color .3s ease; padding: 0 16px; min-width: 140px; width: 100%; text-transform: uppercase; outline: none; + font-family: Roboto; &--disabled, &[disabled] { @@ -71,6 +73,12 @@ } } +.btn-confirm { + color: $white; + border: 2px solid $curious-blue; + background-color: $curious-blue; +} + .btn--large { height: 54px; } @@ -119,19 +127,6 @@ } } -.btn-confirm { - background-color: $caribbean-green; // TODO: reusable color in colors.css - text-align: center; - padding: .8rem 1rem; - color: $white; - border: 2px solid $caribbean-green; - border-radius: 4px; - font-size: .85rem; - font-weight: 400; - transition: border-color .3s ease; - width: 100%; -} - // No longer used in flat design, remove when modal buttons done // div.wallet-btn { // border: 1px solid rgb(91, 93, 103); diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index 1d87b8004..5be040906 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -58,6 +58,4 @@ @import './welcome-screen.scss'; -@import './sender-to-recipient.scss'; - @import '../../../components/index'; diff --git a/ui/app/css/itcss/components/network.scss b/ui/app/css/itcss/components/network.scss index 6c8be0b6d..545a2a940 100644 --- a/ui/app/css/itcss/components/network.scss +++ b/ui/app/css/itcss/components/network.scss @@ -159,15 +159,3 @@ .network-caret { margin: 0 8px 2px; } - -.network-display { - &__container { - display: flex; - align-items: center; - justify-content: flex-start; - - @media screen and (min-width: 576px) { - display: none; - } - } -} diff --git a/ui/app/css/itcss/generic/index.scss b/ui/app/css/itcss/generic/index.scss index 3525d2003..d1c65afed 100644 --- a/ui/app/css/itcss/generic/index.scss +++ b/ui/app/css/itcss/generic/index.scss @@ -73,195 +73,6 @@ input.large-input { text-transform: uppercase; } -.page-container { - width: 408px; - background-color: $white; - box-shadow: 0 0 7px 0 rgba(0, 0, 0, .08); - z-index: 25; - display: flex; - flex-flow: column; - border-radius: 8px; - - &__header { - display: flex; - flex-flow: column; - border-bottom: 1px solid $geyser; - padding: 16px; - flex: 0 0 auto; - position: relative; - - &--no-padding-bottom { - padding-bottom: 0; - } - } - - &__header-close { - color: $tundora; - position: absolute; - top: 16px; - right: 16px; - cursor: pointer; - overflow: hidden; - - &::after { - content: '\00D7'; - font-size: 40px; - line-height: 20px; - } - } - - &__header-row { - padding-bottom: 10px; - display: flex; - justify-content: space-between; - } - - &__footer { - display: flex; - flex-flow: row; - justify-content: center; - border-top: 1px solid $geyser; - padding: 16px; - flex: 0 0 auto; - - .btn-clear, - .btn-cancel, - .btn-confirm { - font-size: 1rem; - } - } - - &__footer-button { - height: 55px; - font-size: 1rem; - text-transform: uppercase; - margin-right: 16px; - border-radius: 2px; - - &:last-of-type { - margin-right: 0; - } - } - - &__back-button { - color: #2f9ae0; - font-size: 1rem; - cursor: pointer; - font-weight: 400; - } - - &__title { - color: $black; - font-size: 2rem; - font-weight: 500; - line-height: 2rem; - } - - &__subtitle { - padding-top: .5rem; - line-height: initial; - font-size: .9rem; - color: $gray; - } - - &__tabs { - display: flex; - margin-top: 16px; - } - - &__tab { - min-width: 5rem; - padding: 8px; - color: $dusty-gray; - font-family: Roboto; - font-size: 1rem; - text-align: center; - cursor: pointer; - border-bottom: none; - margin-right: 16px; - - &:last-of-type { - margin-right: 0; - } - - &--selected { - color: $curious-blue; - border-bottom: 3px solid $curious-blue; - } - } - - &--full-width { - width: 100% !important; - } - - &--full-height { - height: 100% !important; - max-height: initial !important; - min-height: initial !important; - } - - &__content { - overflow-y: auto; - flex: 1; - } - - &__warning-container { - background: $linen; - padding: 20px; - display: flex; - align-items: start; - } - - &__warning-message { - padding-left: 15px; - } - - &__warning-title { - font-weight: 500; - } - - &__warning-icon { - padding-top: 5px; - } -} - -@media screen and (max-width: 250px) { - .page-container { - &__footer { - flex-flow: column-reverse; - } - - &__footer-button { - width: 100%; - margin-bottom: 1rem; - margin-right: 0; - - &:first-of-type { - margin-bottom: 0; - } - } - } -} - -@media screen and (max-width: 575px) { - .page-container { - height: 100%; - width: 100%; - overflow-y: auto; - background-color: $white; - border-radius: 0; - flex: 1; - } -} - -@media screen and (min-width: 576px) { - .page-container { - max-height: 82vh; - min-height: 570px; - flex: 0 0 auto; - } -} - .input-label { padding-bottom: 10px; font-weight: 400; diff --git a/ui/app/css/itcss/settings/variables.scss b/ui/app/css/itcss/settings/variables.scss index 814d7a382..f90c8edc3 100644 --- a/ui/app/css/itcss/settings/variables.scss +++ b/ui/app/css/itcss/settings/variables.scss @@ -55,6 +55,7 @@ $dodger-blue: #3099f2; $zumthor: #edf7ff; $ecstasy: #f7861c; $linen: #fdf4f4; +$oslo-gray: #8C8E94; /* Z-Indicies diff --git a/ui/app/css/itcss/tools/utilities.scss b/ui/app/css/itcss/tools/utilities.scss index ee867640d..209614c6b 100644 --- a/ui/app/css/itcss/tools/utilities.scss +++ b/ui/app/css/itcss/tools/utilities.scss @@ -165,7 +165,7 @@ } .bold { - font-weight: 700; + font-weight: 500; } .text-transform-uppercase { diff --git a/ui/app/ducks/confirm-transaction.duck.js b/ui/app/ducks/confirm-transaction.duck.js new file mode 100644 index 000000000..1885e12d1 --- /dev/null +++ b/ui/app/ducks/confirm-transaction.duck.js @@ -0,0 +1,386 @@ +import { + conversionRateSelector, + currentCurrencySelector, + unconfirmedTransactionsHashSelector, +} from '../selectors/confirm-transaction' + +import { + getTokenData, + getMethodData, + getTransactionAmount, + getTransactionFee, + getHexGasTotal, + addFiat, + addEth, + increaseLastGasPrice, + hexGreaterThan, +} from '../helpers/confirm-transaction/util' + +import { getSymbolAndDecimals } from '../token-util' +import { conversionUtil } from '../conversion-util' + +// Actions +const createActionType = action => `metamask/confirm-transaction/${action}` + +const UPDATE_TX_DATA = createActionType('UPDATE_TX_DATA') +const CLEAR_TX_DATA = createActionType('CLEAR_TX_DATA') +const UPDATE_TOKEN_DATA = createActionType('UPDATE_TOKEN_DATA') +const CLEAR_TOKEN_DATA = createActionType('CLEAR_TOKEN_DATA') +const UPDATE_METHOD_DATA = createActionType('UPDATE_METHOD_DATA') +const CLEAR_METHOD_DATA = createActionType('CLEAR_METHOD_DATA') +const CLEAR_CONFIRM_TRANSACTION = createActionType('CLEAR_CONFIRM_TRANSACTION') +const UPDATE_TRANSACTION_AMOUNTS = createActionType('UPDATE_TRANSACTION_AMOUNTS') +const UPDATE_TRANSACTION_FEES = createActionType('UPDATE_TRANSACTION_FEES') +const UPDATE_TRANSACTION_TOTALS = createActionType('UPDATE_TRANSACTION_TOTALS') +const UPDATE_HEX_GAS_TOTAL = createActionType('UPDATE_HEX_GAS_TOTAL') +const UPDATE_TOKEN_PROPS = createActionType('UPDATE_TOKEN_PROPS') +const UPDATE_NONCE = createActionType('UPDATE_NONCE') +const FETCH_METHOD_DATA_START = createActionType('FETCH_METHOD_DATA_START') +const FETCH_METHOD_DATA_END = createActionType('FETCH_METHOD_DATA_END') + +// Initial state +const initState = { + txData: {}, + tokenData: {}, + methodData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + fiatTransactionAmount: '', + fiatTransactionFee: '', + fiatTransactionTotal: '', + ethTransactionAmount: '', + ethTransactionFee: '', + ethTransactionTotal: '', + hexGasTotal: '', + nonce: '', + fetchingMethodData: false, +} + +// Reducer +export default function reducer ({ confirmTransaction: confirmState = initState }, action = {}) { + switch (action.type) { + case UPDATE_TX_DATA: + return { + ...confirmState, + txData: { + ...action.payload, + }, + } + case CLEAR_TX_DATA: + return { + ...confirmState, + txData: {}, + } + case UPDATE_TOKEN_DATA: + return { + ...confirmState, + tokenData: { + ...action.payload, + }, + } + case CLEAR_TOKEN_DATA: + return { + ...confirmState, + tokenData: {}, + } + case UPDATE_METHOD_DATA: + return { + ...confirmState, + methodData: { + ...action.payload, + }, + } + case CLEAR_METHOD_DATA: + return { + ...confirmState, + methodData: {}, + } + case UPDATE_TRANSACTION_AMOUNTS: + const { fiatTransactionAmount, ethTransactionAmount } = action.payload + return { + ...confirmState, + fiatTransactionAmount: fiatTransactionAmount || confirmState.fiatTransactionAmount, + ethTransactionAmount: ethTransactionAmount || confirmState.ethTransactionAmount, + } + case UPDATE_TRANSACTION_FEES: + const { fiatTransactionFee, ethTransactionFee } = action.payload + return { + ...confirmState, + fiatTransactionFee: fiatTransactionFee || confirmState.fiatTransactionFee, + ethTransactionFee: ethTransactionFee || confirmState.ethTransactionFee, + } + case UPDATE_TRANSACTION_TOTALS: + const { fiatTransactionTotal, ethTransactionTotal } = action.payload + return { + ...confirmState, + fiatTransactionTotal: fiatTransactionTotal || confirmState.fiatTransactionTotal, + ethTransactionTotal: ethTransactionTotal || confirmState.ethTransactionTotal, + } + case UPDATE_HEX_GAS_TOTAL: + return { + ...confirmState, + hexGasTotal: action.payload, + } + case UPDATE_TOKEN_PROPS: + const { tokenSymbol = '', tokenDecimals = '' } = action.payload + return { + ...confirmState, + tokenProps: { + ...confirmState.tokenProps, + tokenSymbol, + tokenDecimals, + }, + } + case UPDATE_NONCE: + return { + ...confirmState, + nonce: action.payload, + } + case FETCH_METHOD_DATA_START: + return { + ...confirmState, + fetchingMethodData: true, + } + case FETCH_METHOD_DATA_END: + return { + ...confirmState, + fetchingMethodData: false, + } + case CLEAR_CONFIRM_TRANSACTION: + return initState + default: + return confirmState + } +} + +// Action Creators +export function updateTxData (txData) { + return { + type: UPDATE_TX_DATA, + payload: txData, + } +} + +export function clearTxData () { + return { + type: CLEAR_TX_DATA, + } +} + +export function updateTokenData (tokenData) { + return { + type: UPDATE_TOKEN_DATA, + payload: tokenData, + } +} + +export function clearTokenData () { + return { + type: CLEAR_TOKEN_DATA, + } +} + +export function updateMethodData (methodData) { + return { + type: UPDATE_METHOD_DATA, + payload: methodData, + } +} + +export function clearMethodData () { + return { + type: CLEAR_METHOD_DATA, + } +} + +export function updateTransactionAmounts (amounts) { + return { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: amounts, + } +} + +export function updateTransactionFees (fees) { + return { + type: UPDATE_TRANSACTION_FEES, + payload: fees, + } +} + +export function updateTransactionTotals (totals) { + return { + type: UPDATE_TRANSACTION_TOTALS, + payload: totals, + } +} + +export function updateHexGasTotal (hexGasTotal) { + return { + type: UPDATE_HEX_GAS_TOTAL, + payload: hexGasTotal, + } +} + +export function updateTokenProps (tokenProps) { + return { + type: UPDATE_TOKEN_PROPS, + payload: tokenProps, + } +} + +export function updateNonce (nonce) { + return { + type: UPDATE_NONCE, + payload: nonce, + } +} + +export function setFetchingMethodData (isFetching) { + return { + type: isFetching ? FETCH_METHOD_DATA_START : FETCH_METHOD_DATA_END, + } +} + +export function updateGasAndCalculate ({ gasLimit, gasPrice }) { + return (dispatch, getState) => { + const { confirmTransaction: { txData } } = getState() + const newTxData = { + ...txData, + txParams: { + ...txData.txParams, + gas: gasLimit, + gasPrice, + }, + } + + dispatch(updateTxDataAndCalculate(newTxData)) + } +} + +function increaseFromLastGasPrice (txData) { + const { lastGasPrice, txParams: { gasPrice: previousGasPrice } = {} } = txData + + // Set the minimum to a 10% increase from the lastGasPrice. + const minimumGasPrice = increaseLastGasPrice(lastGasPrice) + const gasPriceBelowMinimum = hexGreaterThan(minimumGasPrice, previousGasPrice) + const gasPrice = (!previousGasPrice || gasPriceBelowMinimum) ? minimumGasPrice : previousGasPrice + + return { + ...txData, + txParams: { + ...txData.txParams, + gasPrice, + }, + } +} + +export function updateTxDataAndCalculate (txData) { + return (dispatch, getState) => { + const state = getState() + const currentCurrency = currentCurrencySelector(state) + const conversionRate = conversionRateSelector(state) + + dispatch(updateTxData(txData)) + + const { txParams: { value, gas: gasLimit = '0x0', gasPrice = '0x0' } = {} } = txData + + const fiatTransactionAmount = getTransactionAmount({ + value, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, + }) + const ethTransactionAmount = getTransactionAmount({ + value, toCurrency: 'ETH', conversionRate, numberOfDecimals: 6, + }) + + dispatch(updateTransactionAmounts({ fiatTransactionAmount, ethTransactionAmount })) + + const hexGasTotal = getHexGasTotal({ gasLimit, gasPrice }) + + dispatch(updateHexGasTotal(hexGasTotal)) + + const fiatTransactionFee = getTransactionFee({ + value: hexGasTotal, + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate, + }) + const ethTransactionFee = getTransactionFee({ + value: hexGasTotal, + toCurrency: 'ETH', + numberOfDecimals: 6, + conversionRate, + }) + + dispatch(updateTransactionFees({ fiatTransactionFee, ethTransactionFee })) + + const fiatTransactionTotal = addFiat(fiatTransactionFee, fiatTransactionAmount) + const ethTransactionTotal = addEth(ethTransactionFee, ethTransactionAmount) + + dispatch(updateTransactionTotals({ fiatTransactionTotal, ethTransactionTotal })) + } +} + +export function setTransactionToConfirm (transactionId) { + return async (dispatch, getState) => { + const state = getState() + const unconfirmedTransactionsHash = unconfirmedTransactionsHashSelector(state) + const transaction = unconfirmedTransactionsHash[transactionId] + + if (!transaction) { + console.error(`Transaction with id ${transactionId} not found`) + return + } + + if (transaction.txParams) { + const { lastGasPrice } = transaction + const txData = lastGasPrice ? increaseFromLastGasPrice(transaction) : transaction + dispatch(updateTxDataAndCalculate(txData)) + + const { txParams } = transaction + + if (txParams.data) { + const { tokens: existingTokens } = state + const { data, to: tokenAddress } = txParams + + try { + dispatch(setFetchingMethodData(true)) + const methodData = await getMethodData(data) + dispatch(updateMethodData(methodData)) + dispatch(setFetchingMethodData(false)) + } catch (error) { + dispatch(updateMethodData({})) + dispatch(setFetchingMethodData(false)) + } + + const tokenData = getTokenData(data) + dispatch(updateTokenData(tokenData)) + + try { + const tokenSymbolData = await getSymbolAndDecimals(tokenAddress, existingTokens) || {} + const { symbol: tokenSymbol = '', decimals: tokenDecimals = '' } = tokenSymbolData + dispatch(updateTokenProps({ tokenSymbol, tokenDecimals })) + } catch (error) { + dispatch(updateTokenProps({ tokenSymbol: '', tokenDecimals: '' })) + } + } + + if (txParams.nonce) { + const nonce = conversionUtil(txParams.nonce, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + + dispatch(updateNonce(nonce)) + } + } else { + dispatch(updateTxData(transaction)) + } + } +} + +export function clearConfirmTransaction () { + return { + type: CLEAR_CONFIRM_TRANSACTION, + } +} diff --git a/ui/app/ducks/tests/confirm-transaction.duck.test.js b/ui/app/ducks/tests/confirm-transaction.duck.test.js new file mode 100644 index 000000000..111674e33 --- /dev/null +++ b/ui/app/ducks/tests/confirm-transaction.duck.test.js @@ -0,0 +1,675 @@ +import assert from 'assert' +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import ConfirmTransactionReducer, * as actions from '../confirm-transaction.duck.js' + +const initialState = { + txData: {}, + tokenData: {}, + methodData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + fiatTransactionAmount: '', + fiatTransactionFee: '', + fiatTransactionTotal: '', + ethTransactionAmount: '', + ethTransactionFee: '', + ethTransactionTotal: '', + hexGasTotal: '', + nonce: '', + fetchingMethodData: false, +} + +const UPDATE_TX_DATA = 'metamask/confirm-transaction/UPDATE_TX_DATA' +const CLEAR_TX_DATA = 'metamask/confirm-transaction/CLEAR_TX_DATA' +const UPDATE_TOKEN_DATA = 'metamask/confirm-transaction/UPDATE_TOKEN_DATA' +const CLEAR_TOKEN_DATA = 'metamask/confirm-transaction/CLEAR_TOKEN_DATA' +const UPDATE_METHOD_DATA = 'metamask/confirm-transaction/UPDATE_METHOD_DATA' +const CLEAR_METHOD_DATA = 'metamask/confirm-transaction/CLEAR_METHOD_DATA' +const UPDATE_TRANSACTION_AMOUNTS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS' +const UPDATE_TRANSACTION_FEES = 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES' +const UPDATE_TRANSACTION_TOTALS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS' +const UPDATE_HEX_GAS_TOTAL = 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL' +const UPDATE_TOKEN_PROPS = 'metamask/confirm-transaction/UPDATE_TOKEN_PROPS' +const UPDATE_NONCE = 'metamask/confirm-transaction/UPDATE_NONCE' +const FETCH_METHOD_DATA_START = 'metamask/confirm-transaction/FETCH_METHOD_DATA_START' +const FETCH_METHOD_DATA_END = 'metamask/confirm-transaction/FETCH_METHOD_DATA_END' +const CLEAR_CONFIRM_TRANSACTION = 'metamask/confirm-transaction/CLEAR_CONFIRM_TRANSACTION' + +describe('Confirm Transaction Duck', () => { + describe('State changes', () => { + const mockState = { + confirmTransaction: { + txData: { + id: 1, + }, + tokenData: { + name: 'abcToken', + }, + methodData: { + name: 'approve', + }, + tokenProps: { + tokenDecimals: '3', + tokenSymbol: 'ABC', + }, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '1.000021', + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + nonce: '0x0', + fetchingMethodData: false, + }, + } + + it('should initialize state', () => { + assert.deepEqual( + ConfirmTransactionReducer({}), + initialState + ) + }) + + it('should return state unchanged if it does not match a dispatched actions type', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: 'someOtherAction', + value: 'someValue', + }), + { ...mockState.confirmTransaction }, + ) + }) + + it('should set txData when receiving a UPDATE_TX_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TX_DATA, + payload: { + id: 2, + }, + }), + { + ...mockState.confirmTransaction, + txData: { + ...mockState.confirmTransaction.txData, + id: 2, + }, + } + ) + }) + + it('should clear txData when receiving a CLEAR_TX_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_TX_DATA, + }), + { + ...mockState.confirmTransaction, + txData: {}, + } + ) + }) + + it('should set tokenData when receiving a UPDATE_TOKEN_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TOKEN_DATA, + payload: { + name: 'defToken', + }, + }), + { + ...mockState.confirmTransaction, + tokenData: { + ...mockState.confirmTransaction.tokenData, + name: 'defToken', + }, + } + ) + }) + + it('should clear tokenData when receiving a CLEAR_TOKEN_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_TOKEN_DATA, + }), + { + ...mockState.confirmTransaction, + tokenData: {}, + } + ) + }) + + it('should set methodData when receiving a UPDATE_METHOD_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_METHOD_DATA, + payload: { + name: 'transferFrom', + }, + }), + { + ...mockState.confirmTransaction, + methodData: { + ...mockState.confirmTransaction.methodData, + name: 'transferFrom', + }, + } + ) + }) + + it('should clear methodData when receiving a CLEAR_METHOD_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_METHOD_DATA, + }), + { + ...mockState.confirmTransaction, + methodData: {}, + } + ) + }) + + it('should update transaction amounts when receiving an UPDATE_TRANSACTION_AMOUNTS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: { + fiatTransactionAmount: '123.45', + ethTransactionAmount: '.5', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionAmount: '123.45', + ethTransactionAmount: '.5', + } + ) + }) + + it('should update transaction fees when receiving an UPDATE_TRANSACTION_FEES action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_FEES, + payload: { + fiatTransactionFee: '123.45', + ethTransactionFee: '.5', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionFee: '123.45', + ethTransactionFee: '.5', + } + ) + }) + + it('should update transaction totals when receiving an UPDATE_TRANSACTION_TOTALS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_TOTALS, + payload: { + fiatTransactionTotal: '123.45', + ethTransactionTotal: '.5', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionTotal: '123.45', + ethTransactionTotal: '.5', + } + ) + }) + + it('should update hexGasTotal when receiving an UPDATE_HEX_GAS_TOTAL action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_HEX_GAS_TOTAL, + payload: '0x0', + }), + { + ...mockState.confirmTransaction, + hexGasTotal: '0x0', + } + ) + }) + + it('should update tokenProps when receiving an UPDATE_TOKEN_PROPS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TOKEN_PROPS, + payload: { + tokenSymbol: 'DEF', + tokenDecimals: '1', + }, + }), + { + ...mockState.confirmTransaction, + tokenProps: { + tokenSymbol: 'DEF', + tokenDecimals: '1', + }, + } + ) + }) + + it('should update nonce when receiving an UPDATE_NONCE action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_NONCE, + payload: '0x1', + }), + { + ...mockState.confirmTransaction, + nonce: '0x1', + } + ) + }) + + it('should set fetchingMethodData to true when receiving a FETCH_METHOD_DATA_START action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: FETCH_METHOD_DATA_START, + }), + { + ...mockState.confirmTransaction, + fetchingMethodData: true, + } + ) + }) + + it('should set fetchingMethodData to false when receiving a FETCH_METHOD_DATA_END action', () => { + assert.deepEqual( + ConfirmTransactionReducer({ confirmTransaction: { fetchingMethodData: true } }, { + type: FETCH_METHOD_DATA_END, + }), + { + fetchingMethodData: false, + } + ) + }) + + it('should clear confirmTransaction when receiving a FETCH_METHOD_DATA_END action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_CONFIRM_TRANSACTION, + }), + { + ...initialState, + } + ) + }) + }) + + describe('Single actions', () => { + it('should create an action to update txData', () => { + const txData = { test: 123 } + const expectedAction = { + type: UPDATE_TX_DATA, + payload: txData, + } + + assert.deepEqual( + actions.updateTxData(txData), + expectedAction + ) + }) + + it('should create an action to clear txData', () => { + const expectedAction = { + type: CLEAR_TX_DATA, + } + + assert.deepEqual( + actions.clearTxData(), + expectedAction + ) + }) + + it('should create an action to update tokenData', () => { + const tokenData = { test: 123 } + const expectedAction = { + type: UPDATE_TOKEN_DATA, + payload: tokenData, + } + + assert.deepEqual( + actions.updateTokenData(tokenData), + expectedAction + ) + }) + + it('should create an action to clear tokenData', () => { + const expectedAction = { + type: CLEAR_TOKEN_DATA, + } + + assert.deepEqual( + actions.clearTokenData(), + expectedAction + ) + }) + + it('should create an action to update methodData', () => { + const methodData = { test: 123 } + const expectedAction = { + type: UPDATE_METHOD_DATA, + payload: methodData, + } + + assert.deepEqual( + actions.updateMethodData(methodData), + expectedAction + ) + }) + + it('should create an action to clear methodData', () => { + const expectedAction = { + type: CLEAR_METHOD_DATA, + } + + assert.deepEqual( + actions.clearMethodData(), + expectedAction + ) + }) + + it('should create an action to update transaction amounts', () => { + const transactionAmounts = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: transactionAmounts, + } + + assert.deepEqual( + actions.updateTransactionAmounts(transactionAmounts), + expectedAction + ) + }) + + it('should create an action to update transaction fees', () => { + const transactionFees = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_FEES, + payload: transactionFees, + } + + assert.deepEqual( + actions.updateTransactionFees(transactionFees), + expectedAction + ) + }) + + it('should create an action to update transaction totals', () => { + const transactionTotals = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_TOTALS, + payload: transactionTotals, + } + + assert.deepEqual( + actions.updateTransactionTotals(transactionTotals), + expectedAction + ) + }) + + it('should create an action to update hexGasTotal', () => { + const hexGasTotal = '0x0' + const expectedAction = { + type: UPDATE_HEX_GAS_TOTAL, + payload: hexGasTotal, + } + + assert.deepEqual( + actions.updateHexGasTotal(hexGasTotal), + expectedAction + ) + }) + + it('should create an action to update tokenProps', () => { + const tokenProps = { + tokenDecimals: '1', + tokenSymbol: 'abc', + } + const expectedAction = { + type: UPDATE_TOKEN_PROPS, + payload: tokenProps, + } + + assert.deepEqual( + actions.updateTokenProps(tokenProps), + expectedAction + ) + }) + + it('should create an action to update nonce', () => { + const nonce = '0x1' + const expectedAction = { + type: UPDATE_NONCE, + payload: nonce, + } + + assert.deepEqual( + actions.updateNonce(nonce), + expectedAction + ) + }) + + it('should create an action to set fetchingMethodData to true', () => { + const expectedAction = { + type: FETCH_METHOD_DATA_START, + } + + assert.deepEqual( + actions.setFetchingMethodData(true), + expectedAction + ) + }) + + it('should create an action to set fetchingMethodData to false', () => { + const expectedAction = { + type: FETCH_METHOD_DATA_END, + } + + assert.deepEqual( + actions.setFetchingMethodData(false), + expectedAction + ) + }) + + it('should create an action to clear confirmTransaction', () => { + const expectedAction = { + type: CLEAR_CONFIRM_TRANSACTION, + } + + assert.deepEqual( + actions.clearConfirmTransaction(), + expectedAction + ) + }) + }) + + describe('Thunk actions', done => { + it('updates txData and gas on an existing transaction in confirmTransaction', () => { + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + }, + confirmTransaction: { + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '1.000021', + fetchingMethodData: false, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + methodData: {}, + nonce: '', + tokenData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + txData: { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + }, + }, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.updateGasAndCalculate({ gasLimit: '0x2', gasPrice: '0x25' })) + + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + }) + + it('updates txData and updates gas values in confirmTransaction', () => { + const txData = { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x33450', + gasPrice: '0x2540be400', + to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', + value: '0xde0b6b3a7640000', + }, + } + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + }, + confirmTransaction: { + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '1.000021', + fetchingMethodData: false, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + methodData: {}, + nonce: '', + tokenData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + txData: { + ...txData, + txParams: { + ...txData.txParams, + }, + }, + }, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.updateTxDataAndCalculate(txData)) + + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + }) + + it('updates confirmTransaction transaction', done => { + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + network: '3', + unapprovedTxs: { + 2603411941761054: { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x33450', + gasPrice: '0x2540be400', + to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', + value: '0xde0b6b3a7640000', + }, + }, + }, + }, + confirmTransaction: {}, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.setTransactionToConfirm(2603411941761054)) + .then(() => { + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + done() + }) + }) + }) +}) diff --git a/ui/app/helpers/confirm-transaction/util.js b/ui/app/helpers/confirm-transaction/util.js new file mode 100644 index 000000000..ad247a348 --- /dev/null +++ b/ui/app/helpers/confirm-transaction/util.js @@ -0,0 +1,116 @@ +import currencyFormatter from 'currency-formatter' +import currencies from 'currency-formatter/currencies' +import abi from 'human-standard-token-abi' +import abiDecoder from 'abi-decoder' +import ethUtil from 'ethereumjs-util' + +abiDecoder.addABI(abi) + +import MethodRegistry from 'eth-method-registry' +const registry = new MethodRegistry({ provider: global.ethereumProvider }) + +import { + conversionUtil, + addCurrencies, + multiplyCurrencies, + conversionGreaterThan, +} from '../../conversion-util' + +export function getTokenData (data = {}) { + return abiDecoder.decodeMethod(data) +} + +export async function getMethodData (data = {}) { + const prefixedData = ethUtil.addHexPrefix(data) + const fourBytePrefix = prefixedData.slice(0, 10) + const sig = await registry.lookup(fourBytePrefix) + const parsedResult = registry.parse(sig) + + return { + name: parsedResult.name, + params: parsedResult.args, + } +} + +export function increaseLastGasPrice (lastGasPrice) { + return ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { + multiplicandBase: 16, + multiplierBase: 10, + toNumericBase: 'hex', + })) +} + +export function hexGreaterThan (a, b) { + return conversionGreaterThan( + { value: a, fromNumericBase: 'hex' }, + { value: b, fromNumericBase: 'hex' }, + ) +} + +export function getHexGasTotal ({ gasLimit, gasPrice }) { + return ethUtil.addHexPrefix(multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + })) +} + +export function addEth (...args) { + return args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'dec', + numberOfDecimals: 6, + }) + }) +} + +export function addFiat (...args) { + return args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'dec', + numberOfDecimals: 2, + }) + }) +} + +export function getTransactionAmount ({ + value, + toCurrency, + conversionRate, + numberOfDecimals, +}) { + return conversionUtil(value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency, + numberOfDecimals, + fromDenomination: 'WEI', + conversionRate, + }) +} + +export function getTransactionFee ({ + value, + toCurrency, + conversionRate, + numberOfDecimals, +}) { + return conversionUtil(value, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency, + numberOfDecimals, + conversionRate, + }) +} + +export function formatCurrency (value, currencyCode) { + const upperCaseCurrencyCode = currencyCode.toUpperCase() + + return currencies.find(currency => currency.code === upperCaseCurrencyCode) + ? currencyFormatter.format(Number(value), { code: upperCaseCurrencyCode }) + : value +} diff --git a/ui/app/helpers/confirm-transaction/util.test.js b/ui/app/helpers/confirm-transaction/util.test.js new file mode 100644 index 000000000..a9c8fae34 --- /dev/null +++ b/ui/app/helpers/confirm-transaction/util.test.js @@ -0,0 +1,137 @@ +import * as utils from './util' +import assert from 'assert' + +describe('Confirm Transaction utils', () => { + describe('increaseLastGasPrice', () => { + it('should increase the gasPrice by 10%', () => { + const increasedGasPrice = utils.increaseLastGasPrice('0xa') + assert.equal(increasedGasPrice, '0xb') + }) + + it('should prefix the result with 0x', () => { + const increasedGasPrice = utils.increaseLastGasPrice('a') + assert.equal(increasedGasPrice, '0xb') + }) + }) + + describe('hexGreaterThan', () => { + it('should return true if the first value is greater than the second value', () => { + assert.equal( + utils.hexGreaterThan('0xb', '0xa'), + true + ) + }) + + it('should return false if the first value is less than the second value', () => { + assert.equal( + utils.hexGreaterThan('0xa', '0xb'), + false + ) + }) + + it('should return false if the first value is equal to the second value', () => { + assert.equal( + utils.hexGreaterThan('0xa', '0xa'), + false + ) + }) + + it('should correctly compare prefixed and non-prefixed hex values', () => { + assert.equal( + utils.hexGreaterThan('0xb', 'a'), + true + ) + }) + }) + + describe('getHexGasTotal', () => { + it('should multiply the hex gasLimit and hex gasPrice values together', () => { + assert.equal( + utils.getHexGasTotal({ gasLimit: '0x5208', gasPrice: '0x3b9aca00' }), + '0x1319718a5000' + ) + }) + + it('should prefix the result with 0x', () => { + assert.equal( + utils.getHexGasTotal({ gasLimit: '5208', gasPrice: '3b9aca00' }), + '0x1319718a5000' + ) + }) + }) + + describe('addEth', () => { + it('should add two values together rounding to 6 decimal places', () => { + assert.equal( + utils.addEth('0.12345678', '0'), + '0.123457' + ) + }) + + it('should add any number of values together rounding to 6 decimal places', () => { + assert.equal( + utils.addEth('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), + '0.123457' + ) + }) + }) + + describe('addFiat', () => { + it('should add two values together rounding to 2 decimal places', () => { + assert.equal( + utils.addFiat('0.12345678', '0'), + '0.12' + ) + }) + + it('should add any number of values together rounding to 2 decimal places', () => { + assert.equal( + utils.addFiat('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), + '0.12' + ) + }) + }) + + describe('getTransactionAmount', () => { + it('should get the transaction amount in ETH', () => { + const ethTransactionAmount = utils.getTransactionAmount({ + value: '0xde0b6b3a7640000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, + }) + + assert.equal(ethTransactionAmount, '1') + }) + + it('should get the transaction amount in fiat', () => { + const fiatTransactionAmount = utils.getTransactionAmount({ + value: '0xde0b6b3a7640000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, + }) + + assert.equal(fiatTransactionAmount, '468.58') + }) + }) + + describe('getTransactionFee', () => { + it('should get the transaction fee in ETH', () => { + const ethTransactionFee = utils.getTransactionFee({ + value: '0x1319718a5000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, + }) + + assert.equal(ethTransactionFee, '0.000021') + }) + + it('should get the transaction fee in fiat', () => { + const fiatTransactionFee = utils.getTransactionFee({ + value: '0x1319718a5000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, + }) + + assert.equal(fiatTransactionFee, '0.01') + }) + }) + + describe('formatCurrency', () => { + it('should format USD values', () => { + const value = utils.formatCurrency('123.45', 'usd') + assert.equal(value, '$123.45') + }) + }) +}) diff --git a/ui/app/main-container.js b/ui/app/main-container.js index b49a52363..8a0708025 100644 --- a/ui/app/main-container.js +++ b/ui/app/main-container.js @@ -3,9 +3,10 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const AccountAndTransactionDetails = require('./account-and-transaction-details') const Settings = require('./components/pages/settings') -const UnlockScreen = require('./components/pages/unlock-page') const log = require('loglevel') +import UnlockScreen from './components/pages/unlock-page' + module.exports = MainContainer inherits(MainContainer, Component) diff --git a/ui/app/reducers.js b/ui/app/reducers.js index 0b158a778..80e76d570 100644 --- a/ui/app/reducers.js +++ b/ui/app/reducers.js @@ -8,6 +8,7 @@ const reduceMetamask = require('./reducers/metamask') const reduceApp = require('./reducers/app') const reduceLocale = require('./reducers/locale') const reduceSend = require('./ducks/send.duck').default +import reduceConfirmTransaction from './ducks/confirm-transaction.duck' window.METAMASK_CACHED_LOG_STATE = null @@ -45,6 +46,8 @@ function rootReducer (state, action) { state.send = reduceSend(state, action) + state.confirmTransaction = reduceConfirmTransaction(state, action) + window.METAMASK_CACHED_LOG_STATE = state return state } diff --git a/ui/app/routes.js b/ui/app/routes.js index 04f71165f..ec0cc9027 100644 --- a/ui/app/routes.js +++ b/ui/app/routes.js @@ -11,8 +11,6 @@ const NEW_ACCOUNT_ROUTE = '/new-account' const IMPORT_ACCOUNT_ROUTE = '/new-account/import' const CONNECT_HARDWARE_ROUTE = '/new-account/connect' const SEND_ROUTE = '/send' -const CONFIRM_TRANSACTION_ROUTE = '/confirm-transaction' -const SIGNATURE_REQUEST_ROUTE = '/confirm-transaction/signature-request' const NOTICE_ROUTE = '/notice' const WELCOME_ROUTE = '/welcome' const INITIALIZE_ROUTE = '/initialize' @@ -24,6 +22,14 @@ const INITIALIZE_NOTICE_ROUTE = '/initialize/notice' const INITIALIZE_BACKUP_PHRASE_ROUTE = '/initialize/backup-phrase' const INITIALIZE_CONFIRM_SEED_ROUTE = '/initialize/confirm-phrase' +const CONFIRM_TRANSACTION_ROUTE = '/confirm-transaction' +const CONFIRM_SEND_ETHER_PATH = '/send-ether' +const CONFIRM_SEND_TOKEN_PATH = '/send-token' +const CONFIRM_DEPLOY_CONTRACT_PATH = '/deploy-contract' +const CONFIRM_APPROVE_PATH = '/approve' +const CONFIRM_TOKEN_METHOD_PATH = '/token-method' +const SIGNATURE_REQUEST_PATH = '/signature-request' + module.exports = { DEFAULT_ROUTE, UNLOCK_ROUTE, @@ -38,9 +44,7 @@ module.exports = { IMPORT_ACCOUNT_ROUTE, CONNECT_HARDWARE_ROUTE, SEND_ROUTE, - CONFIRM_TRANSACTION_ROUTE, NOTICE_ROUTE, - SIGNATURE_REQUEST_ROUTE, WELCOME_ROUTE, INITIALIZE_ROUTE, INITIALIZE_CREATE_PASSWORD_ROUTE, @@ -50,4 +54,11 @@ module.exports = { INITIALIZE_NOTICE_ROUTE, INITIALIZE_BACKUP_PHRASE_ROUTE, INITIALIZE_CONFIRM_SEED_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, } diff --git a/ui/app/selectors.js b/ui/app/selectors.js index 3e2253550..d86462275 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors.js @@ -27,6 +27,7 @@ const selectors = { autoAddToBetaUI, getSendMaxModeState, getCurrentViewContext, + getTotalUnapprovedCount, } module.exports = selectors @@ -181,3 +182,15 @@ function getCurrentViewContext (state) { const { currentView = {} } = state.appState return currentView.context } + +function getTotalUnapprovedCount ({ metamask }) { + const { + unapprovedTxs = {}, + unapprovedMsgCount, + unapprovedPersonalMsgCount, + unapprovedTypedMessagesCount, + } = metamask + + return Object.keys(unapprovedTxs).length + unapprovedMsgCount + unapprovedPersonalMsgCount + + unapprovedTypedMessagesCount +} diff --git a/ui/app/selectors/confirm-transaction.js b/ui/app/selectors/confirm-transaction.js new file mode 100644 index 000000000..cde83804d --- /dev/null +++ b/ui/app/selectors/confirm-transaction.js @@ -0,0 +1,65 @@ +import { createSelector } from 'reselect' +import txHelper from '../../lib/tx-helper' + +const unapprovedTxsSelector = state => state.metamask.unapprovedTxs +const unapprovedMsgsSelector = state => state.metamask.unapprovedMsgs +const unapprovedPersonalMsgsSelector = state => state.metamask.unapprovedPersonalMsgs +const unapprovedTypedMessagesSelector = state => state.metamask.unapprovedTypedMessages +const networkSelector = state => state.metamask.network + +export const unconfirmedTransactionsListSelector = createSelector( + unapprovedTxsSelector, + unapprovedMsgsSelector, + unapprovedPersonalMsgsSelector, + unapprovedTypedMessagesSelector, + networkSelector, + ( + unapprovedTxs = {}, + unapprovedMsgs = {}, + unapprovedPersonalMsgs = {}, + unapprovedTypedMessages = {}, + network + ) => txHelper( + unapprovedTxs, + unapprovedMsgs, + unapprovedPersonalMsgs, + unapprovedTypedMessages, + network + ) || [] +) + +export const unconfirmedTransactionsHashSelector = createSelector( + unapprovedTxsSelector, + unapprovedMsgsSelector, + unapprovedPersonalMsgsSelector, + unapprovedTypedMessagesSelector, + networkSelector, + ( + unapprovedTxs = {}, + unapprovedMsgs = {}, + unapprovedPersonalMsgs = {}, + unapprovedTypedMessages = {}, + network + ) => { + const filteredUnapprovedTxs = Object.keys(unapprovedTxs).reduce((acc, address) => { + const { metamaskNetworkId } = unapprovedTxs[address] + const transactions = { ...acc } + + if (metamaskNetworkId === network) { + transactions[address] = unapprovedTxs[address] + } + + return transactions + }, {}) + + return { + ...filteredUnapprovedTxs, + ...unapprovedMsgs, + ...unapprovedPersonalMsgs, + ...unapprovedTypedMessages, + } + } +) + +export const currentCurrencySelector = state => state.metamask.currentCurrency +export const conversionRateSelector = state => state.metamask.conversionRate |