diff --git a/.eslintrc b/.eslintrc index 72b3d3e6d..84f65bea4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,6 @@ { "parserOptions": { + "sourceType": "module", "ecmaVersion": 6, "ecmaFeatures": { "experimentalObjectRestSpread": true, @@ -44,7 +45,7 @@ "eol-last": 1, "eqeqeq": [2, "allow-null"], "generator-star-spacing": [2, { "before": true, "after": true }], - "handle-callback-err": [2, "^(err|error)$" ], + "handle-callback-err": [1, "^(err|error)$" ], "indent": [2, 2, { "SwitchCase": 1 }], "jsx-quotes": [2, "prefer-single"], "key-spacing": [2, { "beforeColon": false, "afterColon": true }], @@ -145,6 +146,6 @@ "wrap-iife": [2, "any"], "yield-star-spacing": [2, "both"], "yoda": [2, "never"], - "prefer-const": 1 + "prefer-const": 1, } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1ec6823..fa825e860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Current Master +- Add ability to import accounts in JSON file format (used by Mist, Geth, MyEtherWallet, and more!) +## 3.1.1 2017-1-20 + +- Fix HD wallet seed export + +## 3.1.0 2017-1-18 + +- Add ability to import accounts by private key. +- Fixed bug that returned the wrong transaction hashes on private networks that had not implemented EIP 155 replay protection (like TestRPC). + +## 3.0.1 2017-1-17 + +- Fixed bug that prevented eth.sign from working. +- Fix the displaying of transactions that have been submitted to the network in Transaction History + +## 3.0.0 2017-1-16 + +- Fix seed word account generation (https://medium.com/metamask/metamask-3-migration-guide-914b79533cdd#.t4i1qmmsz). +- Fix Bug where you see a empty transaction flash by on the confirm transaction view. +- Create visible difference in transaction history between a approved but not yet included in a block transaction and a transaction who has been confirmed. - Fix memory leak in RPC Cache - Override RPC commands eth_syncing and web3_clientVersion - Remove certain non-essential permissions from certain builds. @@ -14,6 +34,8 @@ ## 2.14.1 2016-12-20 +- Update Coinbase info. and increase the buy amount to $15 +- Fixed ropsten transaction links - Temporarily disable extension reload detection causing infinite reload bug. - Implemented basic checking for valid RPC URIs. diff --git a/app/manifest.json b/app/manifest.json index 95dcfc31a..c34b17e72 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -1,7 +1,7 @@ { "name": "MetaMask", "short_name": "Metamask", - "version": "2.14.1", + "version": "3.1.1", "manifest_version": 2, "author": "https://metamask.io", "description": "Ethereum Browser Extension", diff --git a/app/scripts/account-import-strategies/index.js b/app/scripts/account-import-strategies/index.js new file mode 100644 index 000000000..d5124eb7f --- /dev/null +++ b/app/scripts/account-import-strategies/index.js @@ -0,0 +1,45 @@ +const Wallet = require('ethereumjs-wallet') +const importers = require('ethereumjs-wallet/thirdparty') +const ethUtil = require('ethereumjs-util') + +const accountImporter = { + + importAccount(strategy, args) { + try { + const importer = this.strategies[strategy] + const privateKeyHex = importer.apply(null, args) + return Promise.resolve(privateKeyHex) + } catch (e) { + return Promise.reject(e) + } + }, + + strategies: { + 'Private Key': (privateKey) => { + const stripped = ethUtil.stripHexPrefix(privateKey) + return stripped + }, + 'JSON File': (input, password) => { + let wallet + try { + wallet = importers.fromEtherWallet(input, password) + } catch (e) { + console.log('Attempt to import as EtherWallet format failed, trying V3...') + } + + if (!wallet) { + wallet = Wallet.fromV3(input, password, true) + } + + return walletToPrivateKey(wallet) + }, + }, + +} + +function walletToPrivateKey (wallet) { + const privateKeyBuffer = wallet.getPrivateKey() + return ethUtil.bufferToHex(privateKeyBuffer) +} + +module.exports = accountImporter diff --git a/app/scripts/background.js b/app/scripts/background.js index 1f269da7b..f95e194dd 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -16,6 +16,7 @@ const firstTimeState = require('./first-time-state') const STORAGE_KEY = 'metamask-config' const METAMASK_DEBUG = 'GULP_METAMASK_DEBUG' + let popupIsOpen = false // state persistence @@ -135,6 +136,7 @@ function setupController (initState) { // User Interface setup // + updateBadge() controller.txManager.on('updateBadge', updateBadge) // plugin badge text diff --git a/app/scripts/keyring-controller.js b/app/scripts/keyring-controller.js index d4c0d863e..86c93f5a3 100644 --- a/app/scripts/keyring-controller.js +++ b/app/scripts/keyring-controller.js @@ -95,7 +95,6 @@ module.exports = class KeyringController extends EventEmitter { isInitialized: (!!wallet || !!vault), isUnlocked: Boolean(this.password), isDisclaimerConfirmed: this.configManager.getConfirmedDisclaimer(), - transactions: this.configManager.getTxList(), unconfMsgs: messageManager.unconfirmedMsgs(), messages: messageManager.getMsgList(), selectedAccount: address, @@ -173,7 +172,9 @@ module.exports = class KeyringController extends EventEmitter { // Used when creating a first vault, to allow confirmation. // Also used when revealing the seed words in the confirmation view. placeSeedWords () { - const firstKeyring = this.keyrings[0] + const hdKeyrings = this.keyrings.filter((keyring) => keyring.type === 'HD Key Tree') + const firstKeyring = hdKeyrings[0] + if (!firstKeyring) throw new Error('KeyringController - No HD Key Tree found') return firstKeyring.serialize() .then((serialized) => { const seedWords = serialized.mnemonic @@ -235,7 +236,10 @@ module.exports = class KeyringController extends EventEmitter { addNewKeyring (type, opts) { const Keyring = this.getKeyringClassForType(type) const keyring = new Keyring(opts) - return keyring.getAccounts() + return keyring.deserialize(opts) + .then(() => { + return keyring.getAccounts() + }) .then((accounts) => { this.keyrings.push(keyring) return this.setupAccounts(accounts) @@ -317,13 +321,11 @@ module.exports = class KeyringController extends EventEmitter { // This method signs tx and returns a promise for // TX Manager to update the state after signing - signTransaction (ethTx, selectedAddress, txId) { - const address = normalize(selectedAddress) - return this.getKeyringForAccount(address) + signTransaction (ethTx, _fromAddress) { + const fromAddress = normalize(_fromAddress) + return this.getKeyringForAccount(fromAddress) .then((keyring) => { - return keyring.signTransaction(address, ethTx) - }).then((tx) => { - return {tx, txId} + return keyring.signTransaction(fromAddress, ethTx) }) } // Add Unconfirmed Message @@ -400,6 +402,7 @@ module.exports = class KeyringController extends EventEmitter { }).then((rawSig) => { cb(null, rawSig) approvalCb(null, true) + messageManager.confirmMsg(msgId) return rawSig }) } catch (e) { diff --git a/app/scripts/keyrings/hd.js b/app/scripts/keyrings/hd.js index 80b713b58..1b9796e07 100644 --- a/app/scripts/keyrings/hd.js +++ b/app/scripts/keyrings/hd.js @@ -76,7 +76,7 @@ class HdKeyring extends EventEmitter { // For eth_sign, we need to sign transactions: signMessage (withAccount, data) { const wallet = this._getWalletForAccount(withAccount) - const message = ethUtil.removeHexPrefix(data) + const message = ethUtil.stripHexPrefix(data) var privKey = wallet.getPrivateKey() var msgSig = ethUtil.ecsign(new Buffer(message, 'hex'), privKey) var rawMsgSig = ethUtil.bufferToHex(sigUtil.concatSig(msgSig.v, msgSig.r, msgSig.s)) diff --git a/app/scripts/keyrings/simple.js b/app/scripts/keyrings/simple.js index 9717f1c45..46687fcaf 100644 --- a/app/scripts/keyrings/simple.js +++ b/app/scripts/keyrings/simple.js @@ -20,13 +20,19 @@ class SimpleKeyring extends EventEmitter { } deserialize (privateKeys = []) { - this.wallets = privateKeys.map((privateKey) => { - const stripped = ethUtil.stripHexPrefix(privateKey) - const buffer = new Buffer(stripped, 'hex') - const wallet = Wallet.fromPrivateKey(buffer) - return wallet + return new Promise((resolve, reject) => { + try { + this.wallets = privateKeys.map((privateKey) => { + const stripped = ethUtil.stripHexPrefix(privateKey) + const buffer = new Buffer(stripped, 'hex') + const wallet = Wallet.fromPrivateKey(buffer) + return wallet + }) + } catch (e) { + reject(e) + } + resolve() }) - return Promise.resolve() } addAccounts (n = 1) { @@ -35,12 +41,12 @@ class SimpleKeyring extends EventEmitter { newWallets.push(Wallet.generate()) } this.wallets = this.wallets.concat(newWallets) - const hexWallets = newWallets.map(w => w.getAddress().toString('hex')) + const hexWallets = newWallets.map(w => ethUtil.bufferToHex(w.getAddress())) return Promise.resolve(hexWallets) } getAccounts () { - return Promise.resolve(this.wallets.map(w => w.getAddress().toString('hex'))) + return Promise.resolve(this.wallets.map(w => ethUtil.bufferToHex(w.getAddress()))) } // tx is an instance of the ethereumjs-transaction class. @@ -54,7 +60,7 @@ class SimpleKeyring extends EventEmitter { // For eth_sign, we need to sign transactions: signMessage (withAccount, data) { const wallet = this._getWalletForAccount(withAccount) - const message = ethUtil.removeHexPrefix(data) + const message = ethUtil.stripHexPrefix(data) var privKey = wallet.getPrivateKey() var msgSig = ethUtil.ecsign(new Buffer(message, 'hex'), privKey) var rawMsgSig = ethUtil.bufferToHex(sigUtil.concatSig(msgSig.v, msgSig.r, msgSig.s)) @@ -70,7 +76,9 @@ class SimpleKeyring extends EventEmitter { /* PRIVATE METHODS */ _getWalletForAccount (account) { - return this.wallets.find(w => w.getAddress().toString('hex') === account) + let wallet = this.wallets.find(w => ethUtil.bufferToHex(w.getAddress()) === account) + if (!wallet) throw new Error('Simple Keyring - Unable to find matching address.') + return wallet } } diff --git a/app/scripts/lib/config-manager.js b/app/scripts/lib/config-manager.js index 6d7305377..daba8bc7b 100644 --- a/app/scripts/lib/config-manager.js +++ b/app/scripts/lib/config-manager.js @@ -281,7 +281,7 @@ ConfigManager.prototype.updateConversionRate = function () { this.setConversionPrice(parsedResponse.ticker.price) this.setConversionDate(parsedResponse.timestamp) }).catch((err) => { - console.error('Error in conversion.', err) + console.warn('MetaMask - Failed to query currency conversion.') this.setConversionPrice(0) this.setConversionDate('N/A') }) diff --git a/app/scripts/lib/eth-store.js b/app/scripts/lib/eth-store.js index a42b2417f..7e2caf884 100644 --- a/app/scripts/lib/eth-store.js +++ b/app/scripts/lib/eth-store.js @@ -43,7 +43,9 @@ EthereumStore.prototype.addAccount = function (address) { self._currentState.accounts[address] = {} self._didUpdate() if (!self.currentBlockNumber) return - self._updateAccount(address, noop) + self._updateAccount(address, () => { + self._didUpdate() + }) } EthereumStore.prototype.removeAccount = function (address) { diff --git a/app/scripts/lib/tx-utils.js b/app/scripts/lib/tx-utils.js index d1fb98f42..5116cb93b 100644 --- a/app/scripts/lib/tx-utils.js +++ b/app/scripts/lib/tx-utils.js @@ -1,6 +1,8 @@ const async = require('async') const EthQuery = require('eth-query') const ethUtil = require('ethereumjs-util') +const Transaction = require('ethereumjs-tx') +const normalize = require('./sig-util').normalize const BN = ethUtil.BN /* @@ -14,6 +16,7 @@ module.exports = class txProviderUtils { this.provider = provider this.query = new EthQuery(provider) } + analyzeGasUsage (txData, cb) { var self = this this.query.getBlockByNumber('latest', true, (err, block) => { @@ -71,4 +74,59 @@ module.exports = class txProviderUtils { const correct = bnGas.add(gasBuffer) return ethUtil.addHexPrefix(correct.toString(16)) } + + fillInTxParams (txParams, cb) { + let fromAddress = txParams.from + let reqs = {} + + if (isUndef(txParams.gas)) reqs.gas = (cb) => this.query.estimateGas(txParams, cb) + if (isUndef(txParams.gasPrice)) reqs.gasPrice = (cb) => this.query.gasPrice(cb) + if (isUndef(txParams.nonce)) reqs.nonce = (cb) => this.query.getTransactionCount(fromAddress, 'pending', cb) + + async.parallel(reqs, function(err, result) { + if (err) return cb(err) + // write results to txParams obj + Object.assign(txParams, result) + cb() + }) + } + + // builds ethTx from txParams object + buildEthTxFromParams (txParams, gasMultiplier = 1) { + // apply gas multiplyer + let gasPrice = new BN(ethUtil.stripHexPrefix(txParams.gasPrice), 16) + // multiply and divide by 100 so as to add percision to integer mul + gasPrice = gasPrice.mul(new BN(gasMultiplier * 100, 10)).div(new BN(100, 10)) + txParams.gasPrice = ethUtil.intToHex(gasPrice.toNumber()) + // normalize values + txParams.to = normalize(txParams.to) + txParams.from = normalize(txParams.from) + txParams.value = normalize(txParams.value) + txParams.data = normalize(txParams.data) + txParams.gasLimit = normalize(txParams.gasLimit || txParams.gas) + txParams.nonce = normalize(txParams.nonce) + // build ethTx + const ethTx = new Transaction(txParams) + return ethTx + } + + publishTransaction (rawTx, cb) { + this.query.sendRawTransaction(rawTx, cb) + } + + validateTxParams (txParams, cb) { + if (('value' in txParams) && txParams.value.indexOf('-') === 0) { + cb(new Error(`Invalid transaction value of ${txParams.value} not a positive number.`)) + } else { + cb() + } + } + + +} + +// util + +function isUndef(value) { + return value === undefined } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e15844a56..2847873bd 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -15,6 +15,8 @@ const IdStoreMigrator = require('./lib/idStore-migrator') const ObservableStore = require('./lib/observable/') const HostStore = require('./lib/observable/host') const synchronizeStore = require('./lib/observable/util/sync') +const accountImporter = require('./account-import-strategies') + const version = require('../manifest.json').version module.exports = class MetamaskController extends EventEmitter { @@ -57,6 +59,7 @@ module.exports = class MetamaskController extends EventEmitter { getSelectedAccount: this.configManager.getSelectedAccount.bind(this.configManager), getGasMultiplier: this.configManager.getGasMultiplier.bind(this.configManager), getNetwork: this.getStateNetwork.bind(this), + signTransaction: this.keyringController.signTransaction.bind(this.keyringController), provider: this.provider, blockTracker: this.provider, }) @@ -77,6 +80,7 @@ module.exports = class MetamaskController extends EventEmitter { this.ethStore.on('update', this.sendUpdate.bind(this)) this.keyringController.on('update', this.sendUpdate.bind(this)) + this.txManager.on('update', this.sendUpdate.bind(this)) } getState () { @@ -125,7 +129,22 @@ module.exports = class MetamaskController extends EventEmitter { .then((newState) => { cb(null, newState) }) .catch((reason) => { cb(reason) }) }, - addNewKeyring: nodeify(keyringController.addNewKeyring).bind(keyringController), + addNewKeyring: (type, opts, cb) => { + keyringController.addNewKeyring(type, opts) + .then(() => keyringController.fullUpdate()) + .then((newState) => { cb(null, newState) }) + .catch((reason) => { cb(reason) }) + }, + importAccountWithStrategy: (strategy, args, cb) => { + accountImporter.importAccount(strategy, args) + .then((privateKey) => { + return keyringController.addNewKeyring('Simple Key Pair', [ privateKey ]) + }) + .then(keyring => keyring.getAccounts()) + .then((accounts) => keyringController.setSelectedAccount(accounts[0])) + .then(() => { cb(null, keyringController.fullUpdate()) }) + .catch((reason) => { cb(reason) }) + }, addNewAccount: nodeify(keyringController.addNewAccount).bind(keyringController), setSelectedAccount: nodeify(keyringController.setSelectedAccount).bind(keyringController), saveAccountLabel: nodeify(keyringController.saveAccountLabel).bind(keyringController), @@ -200,26 +219,7 @@ module.exports = class MetamaskController extends EventEmitter { cb(null, result) }, // tx signing - approveTransaction: this.newUnsignedTransaction.bind(this), - signTransaction: (txParams, cb) => { - this.txManager.formatTxForSigining(txParams) - .then(({ethTx, address, txId}) => { - return this.keyringController.signTransaction(ethTx, address, txId) - }) - .then(({tx, txId}) => { - return this.txManager.resolveSignedTransaction({tx, txId}) - }) - .then((rawTx) => { - cb(null, rawTx) - this.sendUpdate() - this.txManager.emit(`${txParams.metamaskId}:signingComplete`) - }) - .catch((err) => { - console.error(err) - cb(err) - }) - }, - + processTransaction: (txParams, cb) => this.newUnapprovedTransaction(txParams, cb), // msg signing approveMessage: this.newUnsignedMessage.bind(this), signMessage: (...args) => { @@ -259,24 +259,26 @@ module.exports = class MetamaskController extends EventEmitter { return publicConfigStore } - newUnsignedTransaction (txParams, onTxDoneCb) { - const txManager = this.txManager - const err = this.enforceTxValidations(txParams) - if (err) return onTxDoneCb(err) - txManager.addUnapprovedTransaction(txParams, onTxDoneCb, (err, txData) => { - if (err) return onTxDoneCb(err) - this.sendUpdate() - this.opts.showUnapprovedTx(txParams, txData, onTxDoneCb) + newUnapprovedTransaction (txParams, cb) { + const self = this + self.txManager.addUnapprovedTransaction(txParams, (err, txMeta) => { + if (err) return cb(err) + self.sendUpdate() + self.opts.showUnapprovedTx(txMeta) + // listen for tx completion (success, fail) + self.txManager.once(`${txMeta.id}:finished`, (status) => { + switch (status) { + case 'submitted': + return cb(null, txMeta.hash) + case 'rejected': + return cb(new Error('MetaMask Tx Signature: User denied transaction signature.')) + default: + return cb(new Error(`MetaMask Tx Signature: Unknown problem: ${JSON.stringify(txMeta.txParams)}`)) + } + }) }) } - enforceTxValidations (txParams) { - if (('value' in txParams) && txParams.value.indexOf('-') === 0) { - const msg = `Invalid transaction value of ${txParams.value} not a positive number.` - return new Error(msg) - } - } - newUnsignedMessage (msgParams, cb) { var state = this.keyringController.getState() if (!state.isUnlocked) { diff --git a/app/scripts/transaction-manager.js b/app/scripts/transaction-manager.js index 6becfa6d1..6d0121afd 100644 --- a/app/scripts/transaction-manager.js +++ b/app/scripts/transaction-manager.js @@ -1,11 +1,11 @@ const EventEmitter = require('events') +const async = require('async') const extend = require('xtend') +const Semaphore = require('semaphore') const ethUtil = require('ethereumjs-util') -const Transaction = require('ethereumjs-tx') -const BN = ethUtil.BN +const BN = require('ethereumjs-util').BN const TxProviderUtil = require('./lib/tx-utils') const createId = require('./lib/random-id') -const normalize = require('./lib/sig-util').normalize module.exports = class TransactionManager extends EventEmitter { constructor (opts) { @@ -20,6 +20,8 @@ module.exports = class TransactionManager extends EventEmitter { this.blockTracker.on('block', this.checkForTxInBlock.bind(this)) this.getGasMultiplier = opts.getGasMultiplier this.getNetwork = opts.getNetwork + this.signEthTx = opts.signTransaction + this.nonceLock = Semaphore(1) } getState () { @@ -33,11 +35,12 @@ module.exports = class TransactionManager extends EventEmitter { // Returns the tx list getTxList () { - return this.txList + let network = this.getNetwork() + return this.txList.filter(txMeta => txMeta.metamaskNetworkId === network) } // Adds a tx to the txlist - addTx (txMeta, onTxDoneCb = warn) { + addTx (txMeta) { var txList = this.getTxList() var txHistoryLimit = this.txHistoryLimit @@ -53,16 +56,11 @@ module.exports = class TransactionManager extends EventEmitter { txList.push(txMeta) this._saveTxList(txList) - // keep the onTxDoneCb around in a listener - // for after approval/denial (requires user interaction) - // This onTxDoneCb fires completion to the Dapp's write operation. this.once(`${txMeta.id}:signed`, function (txId) { this.removeAllListeners(`${txMeta.id}:rejected`) - onTxDoneCb(null, true) }) this.once(`${txMeta.id}:rejected`, function (txId) { this.removeAllListeners(`${txMeta.id}:signed`) - onTxDoneCb(null, false) }) this.emit('updateBadge') @@ -83,6 +81,7 @@ module.exports = class TransactionManager extends EventEmitter { var index = txList.findIndex(txData => txData.id === txId) txList[index] = txMeta this._saveTxList(txList) + this.emit('update') } get unapprovedTxCount () { @@ -93,28 +92,51 @@ module.exports = class TransactionManager extends EventEmitter { return this.getTxsByMetaData('status', 'signed').length } - addUnapprovedTransaction (txParams, onTxDoneCb, cb) { - // create txData obj with parameters and meta data - var time = (new Date()).getTime() - var txId = createId() - txParams.metamaskId = txId - txParams.metamaskNetworkId = this.getNetwork() - var txData = { - id: txId, - txParams: txParams, - time: time, - status: 'unapproved', - gasMultiplier: this.getGasMultiplier() || 1, - metamaskNetworkId: this.getNetwork(), - } - this.txProviderUtils.analyzeGasUsage(txData, this.txDidComplete.bind(this, txData, onTxDoneCb, cb)) - // calculate metadata for tx + addUnapprovedTransaction (txParams, done) { + let txMeta + async.waterfall([ + // validate + (cb) => this.txProviderUtils.validateTxParams(txParams, cb), + // prepare txMeta + (cb) => { + // create txMeta obj with parameters and meta data + let time = (new Date()).getTime() + let txId = createId() + txParams.metamaskId = txId + txParams.metamaskNetworkId = this.getNetwork() + txMeta = { + id: txId, + time: time, + status: 'unapproved', + gasMultiplier: this.getGasMultiplier() || 1, + metamaskNetworkId: this.getNetwork(), + txParams: txParams, + } + // calculate metadata for tx + this.txProviderUtils.analyzeGasUsage(txMeta, cb) + }, + // save txMeta + (cb) => { + this.addTx(txMeta) + this.setMaxTxCostAndFee(txMeta) + cb(null, txMeta) + }, + ], done) } - txDidComplete (txMeta, onTxDoneCb, cb, err) { - if (err) return cb(err) - this.addTx(txMeta, onTxDoneCb) - cb(null, txMeta) + setMaxTxCostAndFee (txMeta) { + var txParams = txMeta.txParams + var gasMultiplier = txMeta.gasMultiplier + var gasCost = new BN(ethUtil.stripHexPrefix(txParams.gas || txMeta.estimatedGas), 16) + var gasPrice = new BN(ethUtil.stripHexPrefix(txParams.gasPrice || '0x4a817c800'), 16) + gasPrice = gasPrice.mul(new BN(gasMultiplier * 100), 10).div(new BN(100, 10)) + var txFee = gasCost.mul(gasPrice) + var txValue = new BN(ethUtil.stripHexPrefix(txParams.value || '0x0'), 16) + var maxCost = txValue.add(txFee) + txMeta.txFee = txFee + txMeta.txValue = txValue + txMeta.maxCost = maxCost + this.updateTx(txMeta) } getUnapprovedTxList () { @@ -127,8 +149,25 @@ module.exports = class TransactionManager extends EventEmitter { } approveTransaction (txId, cb = warn) { - this.setTxStatusSigned(txId) - this.once(`${txId}:signingComplete`, cb) + const self = this + // approve + self.setTxStatusApproved(txId) + // only allow one tx at a time for atomic nonce usage + self.nonceLock.take(() => { + // begin signature process + async.waterfall([ + (cb) => self.fillInTxParams(txId, cb), + (cb) => self.signTransaction(txId, cb), + (rawTx, cb) => self.publishTransaction(txId, rawTx, cb), + ], (err) => { + self.nonceLock.leave() + if (err) { + this.setTxStatusFailed(txId) + return cb(err) + } + cb() + }) + }) } cancelTransaction (txId, cb = warn) { @@ -136,38 +175,43 @@ module.exports = class TransactionManager extends EventEmitter { cb() } - // formats txParams so the keyringController can sign it - formatTxForSigining (txParams) { - var address = txParams.from - var metaTx = this.getTx(txParams.metamaskId) - var gasMultiplier = metaTx.gasMultiplier - var gasPrice = new BN(ethUtil.stripHexPrefix(txParams.gasPrice), 16) - gasPrice = gasPrice.mul(new BN(gasMultiplier * 100, 10)).div(new BN(100, 10)) - txParams.gasPrice = ethUtil.intToHex(gasPrice.toNumber()) - - // normalize values - txParams.to = normalize(txParams.to) - txParams.from = normalize(txParams.from) - txParams.value = normalize(txParams.value) - txParams.data = normalize(txParams.data) - txParams.gasLimit = normalize(txParams.gasLimit || txParams.gas) - txParams.nonce = normalize(txParams.nonce) - const ethTx = new Transaction(txParams) - var txId = txParams.metamaskId - return Promise.resolve({ethTx, address, txId}) + fillInTxParams (txId, cb) { + let txMeta = this.getTx(txId) + this.txProviderUtils.fillInTxParams(txMeta.txParams, (err) => { + if (err) return cb(err) + this.updateTx(txMeta) + cb() + }) } - // receives a signed tx object and updates the tx hash - // and pass it to the cb to be sent off - resolveSignedTransaction ({tx, txId, cb = warn}) { - // Add the tx hash to the persisted meta-tx object - var txHash = ethUtil.bufferToHex(tx.hash()) - var metaTx = this.getTx(txId) - metaTx.hash = txHash - this.updateTx(metaTx) - var rawTx = ethUtil.bufferToHex(tx.serialize()) - return Promise.resolve(rawTx) + signTransaction (txId, cb) { + let txMeta = this.getTx(txId) + let txParams = txMeta.txParams + let fromAddress = txParams.from + let ethTx = this.txProviderUtils.buildEthTxFromParams(txParams, txMeta.gasMultiplier) + this.signEthTx(ethTx, fromAddress).then(() => { + this.setTxStatusSigned(txMeta.id) + cb(null, ethUtil.bufferToHex(ethTx.serialize())) + }).catch((err) => { + cb(err) + }) + } + + publishTransaction (txId, rawTx, cb) { + this.txProviderUtils.publishTransaction(rawTx, (err, txHash) => { + if (err) return cb(err) + this.setTxHash(txId, txHash) + this.setTxStatusSubmitted(txId) + cb() + }) + } + // receives a txHash records the tx as signed + setTxHash (txId, txHash) { + // Add the tx hash to the persisted meta-tx object + let txMeta = this.getTx(txId) + txMeta.hash = txHash + this.updateTx(txMeta) } /* @@ -212,23 +256,35 @@ module.exports = class TransactionManager extends EventEmitter { return txMeta.status } + // should update the status of the tx to 'rejected'. + setTxStatusRejected (txId) { + this._setTxStatus(txId, 'rejected') + } + + // should update the status of the tx to 'approved'. + setTxStatusApproved (txId) { + this._setTxStatus(txId, 'approved') + } // should update the status of the tx to 'signed'. setTxStatusSigned (txId) { this._setTxStatus(txId, 'signed') - this.emit('updateBadge') } - // should update the status of the tx to 'rejected'. - setTxStatusRejected (txId) { - this._setTxStatus(txId, 'rejected') - this.emit('updateBadge') + // should update the status of the tx to 'submitted'. + setTxStatusSubmitted (txId) { + this._setTxStatus(txId, 'submitted') } + // should update the status of the tx to 'confirmed'. setTxStatusConfirmed (txId) { this._setTxStatus(txId, 'confirmed') } + setTxStatusFailed (txId) { + this._setTxStatus(txId, 'failed') + } + // merges txParams obj onto txData.txParams // use extend to ensure that all fields are filled updateTxParams (txId, txParams) { @@ -240,19 +296,31 @@ module.exports = class TransactionManager extends EventEmitter { // checks if a signed tx is in a block and // if included sets the tx status as 'confirmed' checkForTxInBlock () { - var signedTxList = this.getFilteredTxList({status: 'signed', err: undefined}) + var signedTxList = this.getFilteredTxList({status: 'submitted'}) if (!signedTxList.length) return - signedTxList.forEach((tx) => { - var txHash = tx.hash - var txId = tx.id - if (!txHash) return - this.txProviderUtils.query.getTransactionByHash(txHash, (err, txMeta) => { - if (err || !txMeta) { - tx.err = err || 'Tx could possibly have not been submitted' - this.updateTx(tx) - return txMeta ? console.error(err) : console.debug(`txMeta is ${txMeta} for:`, tx) + signedTxList.forEach((txMeta) => { + var txHash = txMeta.hash + var txId = txMeta.id + if (!txHash) { + txMeta.err = { + errCode: 'No hash was provided', + message: 'We had an error while submitting this transaction, please try again.', } - if (txMeta.blockNumber) { + this.updateTx(txMeta) + return this.setTxStatusFailed(txId) + } + this.txProviderUtils.query.getTransactionByHash(txHash, (err, txParams) => { + if (err || !txParams) { + if (!txParams) return + txMeta.err = { + isWarning: true, + errorCode: err, + message: 'There was a problem loading this transaction.', + } + this.updateTx(txMeta) + return console.error(err) + } + if (txParams.blockNumber) { this.setTxStatusConfirmed(txId) } }) @@ -266,6 +334,7 @@ module.exports = class TransactionManager extends EventEmitter { // should set the status in txData // - `'unapproved'` the user has not responded // - `'rejected'` the user has responded no! + // - `'approved'` the user has approved the tx // - `'signed'` the tx is signed // - `'submitted'` the tx is sent to a server // - `'confirmed'` the tx has been included in a block. @@ -273,7 +342,11 @@ module.exports = class TransactionManager extends EventEmitter { var txMeta = this.getTx(txId) txMeta.status = status this.emit(`${txMeta.id}:${status}`, txId) + if (status === 'submitted' || status === 'rejected') { + this.emit(`${txMeta.id}:finished`, status) + } this.updateTx(txMeta) + this.emit('updateBadge') } // Saves the new/updated txList. diff --git a/development/states/account-list-with-imported.json b/development/states/account-list-with-imported.json new file mode 100644 index 000000000..e32327743 --- /dev/null +++ b/development/states/account-list-with-imported.json @@ -0,0 +1,84 @@ +{ + "metamask": { + "isInitialized": true, + "isUnlocked": true, + "rpcTarget": "https://rawtestrpc.metamask.io/", + "identities": { + "0x58bda1f9d87dc7d2bcc6f7c2513efc9d03fca683": { + "address": "0x58bda1f9d87dc7d2bcc6f7c2513efc9d03fca683", + "name": "Account 1" + }, + "0x9858e7d8b79fc3e6d989636721584498926da38a": { + "address": "0x9858e7d8b79fc3e6d989636721584498926da38a", + "name": "Imported Account" + } + }, + "unconfTxs": {}, + "currentFiat": "USD", + "conversionRate": 10.19458075, + "conversionDate": 1484696373, + "noActiveNotices": true, + "network": "3", + "accounts": { + "0x58bda1f9d87dc7d2bcc6f7c2513efc9d03fca683": { + "code": "0x", + "balance": "0x0", + "nonce": "0x0", + "address": "0x58bda1f9d87dc7d2bcc6f7c2513efc9d03fca683" + }, + "0x9858e7d8b79fc3e6d989636721584498926da38a": { + "code": "0x", + "balance": "0x0", + "nonce": "0x0", + "address": "0x9858e7d8b79fc3e6d989636721584498926da38a" + } + }, + "transactions": [], + "provider": { + "type": "testnet" + }, + "selectedAccount": "0x9858e7d8b79fc3e6d989636721584498926da38a", + "selectedAccountTxList": [], + "isDisclaimerConfirmed": true, + "unconfMsgs": {}, + "messages": [], + "shapeShiftTxList": [], + "keyringTypes": [ + "Simple Key Pair", + "HD Key Tree" + ], + "keyrings": [ + { + "type": "HD Key Tree", + "accounts": [ + "58bda1f9d87dc7d2bcc6f7c2513efc9d03fca683" + ] + }, + { + "type": "Simple Key Pair", + "accounts": [ + "0x9858e7d8b79fc3e6d989636721584498926da38a" + ] + } + ], + "lostAccounts": [], + "seedWords": null + }, + "appState": { + "menuOpen": false, + "currentView": { + "name": "accounts" + }, + "accountDetail": { + "subview": "transactions", + "accountExport": "none", + "privateKey": "" + }, + "transForward": true, + "isLoading": false, + "warning": null, + "scrollToBottom": false, + "forgottenPassword": false + }, + "identities": {} +} \ No newline at end of file diff --git a/development/states/compilation-bug.json b/development/states/compilation-bug.json new file mode 100644 index 000000000..a9dfc4d4e --- /dev/null +++ b/development/states/compilation-bug.json @@ -0,0 +1,124 @@ +{ + "metamask": { + "isInitialized": true, + "isUnlocked": true, + "rpcTarget": "https://rawtestrpc.metamask.io/", + "identities": { + "0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9": { + "address": "0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9", + "name": "Account 1" + }, + "0xd7c0cd9e7d2701c710d64fc492c7086679bdf7b4": { + "address": "0xd7c0cd9e7d2701c710d64fc492c7086679bdf7b4", + "name": "Account 2" + }, + "0x1acfb961c5a8268eac8e09d6241a26cbeff42241": { + "address": "0x1acfb961c5a8268eac8e09d6241a26cbeff42241", + "name": "Account 3" + }, + "0xabc2bca51709b8615147352c62420f547a63a00c": { + "address": "0xabc2bca51709b8615147352c62420f547a63a00c", + "name": "Account 4" + } + }, + "unconfTxs": { + "7992944905869041": { + "id": 7992944905869041, + "txParams": { + "from": "0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9", + "value": "0x0", + "data": "0x606060405234610000575b60da806100186000396000f30060606040526000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630dbe671f14603c575b6000565b3460005760466088565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b600060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16815600a165627a7a72305820a99dfa6091771f518dd1ae8d1ee347bae3304dffd98fd24b1b99a8380bc60a750029", + "gas": "0x1af75", + "metamaskId": 7992944905869041, + "metamaskNetworkId": "3" + }, + "time": 1482279685589, + "status": "unconfirmed", + "gasMultiplier": 1, + "metamaskNetworkId": "3", + "gasLimitSpecified": true, + "estimatedGas": "0x1af75", + "simulationFails": true + } + }, + "currentFiat": "USD", + "conversionRate": 7.69158136, + "conversionDate": 1482279663, + "noActiveNotices": true, + "network": "3", + "accounts": { + "0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9": { + "code": "0x", + "nonce": "0x3", + "balance": "0x11f646fe14c9c000", + "address": "0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9" + }, + "0xd7c0cd9e7d2701c710d64fc492c7086679bdf7b4": { + "code": "0x", + "nonce": "0x0", + "balance": "0x0", + "address": "0xd7c0cd9e7d2701c710d64fc492c7086679bdf7b4" + }, + "0x1acfb961c5a8268eac8e09d6241a26cbeff42241": { + "code": "0x", + "balance": "0x0", + "nonce": "0x0", + "address": "0x1acfb961c5a8268eac8e09d6241a26cbeff42241" + }, + "0xabc2bca51709b8615147352c62420f547a63a00c": { + "code": "0x", + "balance": "0x0", + "nonce": "0x0", + "address": "0xabc2bca51709b8615147352c62420f547a63a00c" + } + }, + "transactions": [ + { + "id": 7992944905869041, + "txParams": { + "from": "0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9", + "value": "0x0", + "data": "0x606060405234610000575b60da806100186000396000f30060606040526000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630dbe671f14603c575b6000565b3460005760466088565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b600060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16815600a165627a7a72305820a99dfa6091771f518dd1ae8d1ee347bae3304dffd98fd24b1b99a8380bc60a750029", + "gas": "0x1af75", + "metamaskId": 7992944905869041, + "metamaskNetworkId": "3" + }, + "time": 1482279685589, + "status": "unconfirmed", + "gasMultiplier": 1, + "metamaskNetworkId": "3", + "gasLimitSpecified": true, + "estimatedGas": "0x1af75", + "simulationFails": true + } + ], + "provider": { + "type": "testnet" + }, + "selectedAccount": "0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9", + "seedWords": false, + "isDisclaimerConfirmed": true, + "unconfMsgs": {}, + "messages": [], + "shapeShiftTxList": [], + "keyringTypes": [ + "Simple Key Pair", + "HD Key Tree" + ], + "lostAccounts": [] + }, + "appState": { + "menuOpen": false, + "currentView": { + "name": "confTx", + "context": 0 + }, + "accountDetail": { + "subview": "transactions" + }, + "transForward": true, + "isLoading": false, + "warning": null + }, + "identities": {} +} \ No newline at end of file diff --git a/development/states/import-private-key-warning.json b/development/states/import-private-key-warning.json new file mode 100644 index 000000000..f4ac99b05 --- /dev/null +++ b/development/states/import-private-key-warning.json @@ -0,0 +1,92 @@ +{ + "metamask": { + "isInitialized": true, + "isUnlocked": true, + "rpcTarget": "https://rawtestrpc.metamask.io/", + "identities": { + "0x01208723ba84e15da2e71656544a2963b0c06d40": { + "address": "0x01208723ba84e15da2e71656544a2963b0c06d40", + "name": "Account 1" + } + }, + "unconfTxs": {}, + "currentFiat": "USD", + "conversionRate": 10.1219126, + "conversionDate": 1484695442, + "noActiveNotices": true, + "network": "3", + "accounts": { + "0x01208723ba84e15da2e71656544a2963b0c06d40": { + "nonce": "0x0", + "balance": "0x0", + "code": "0x", + "address": "0x01208723ba84e15da2e71656544a2963b0c06d40" + } + }, + "transactions": [], + "provider": { + "type": "testnet" + }, + "selectedAccount": "0x01208723ba84e15da2e71656544a2963b0c06d40", + "selectedAccountTxList": [], + "seedWords": false, + "isDisclaimerConfirmed": true, + "unconfMsgs": {}, + "messages": [], + "shapeShiftTxList": [], + "keyringTypes": [ + "Simple Key Pair", + "HD Key Tree" + ], + "keyrings": [ + { + "type": "Simple Key Pair", + "accounts": [] + }, + { + "type": "Simple Key Pair", + "accounts": [] + }, + { + "type": "Simple Key Pair", + "accounts": [] + }, + { + "type": "Simple Key Pair", + "accounts": [] + }, + { + "type": "Simple Key Pair", + "accounts": [] + }, + { + "type": "Simple Key Pair", + "accounts": [] + }, + { + "type": "Simple Key Pair", + "accounts": [] + }, + { + "type": "HD Key Tree", + "accounts": [ + "01208723ba84e15da2e71656544a2963b0c06d40" + ] + } + ], + "lostAccounts": [] + }, + "appState": { + "menuOpen": false, + "currentView": { + "name": "import-menu" + }, + "accountDetail": { + "subview": "transactions" + }, + "transForward": true, + "isLoading": false, + "warning": "Invalid hex string" + }, + "identities": {} +} \ No newline at end of file diff --git a/development/states/import-private-key.json b/development/states/import-private-key.json new file mode 100644 index 000000000..c70f02a36 --- /dev/null +++ b/development/states/import-private-key.json @@ -0,0 +1,64 @@ +{ + "metamask": { + "isInitialized": true, + "isUnlocked": true, + "rpcTarget": "https://rawtestrpc.metamask.io/", + "identities": { + "0x01208723ba84e15da2e71656544a2963b0c06d40": { + "address": "0x01208723ba84e15da2e71656544a2963b0c06d40", + "name": "Account 1" + } + }, + "unconfTxs": {}, + "currentFiat": "USD", + "conversionRate": 10.10788584, + "conversionDate": 1484694362, + "noActiveNotices": true, + "network": "3", + "accounts": { + "0x01208723ba84e15da2e71656544a2963b0c06d40": { + "balance": "0x0", + "code": "0x", + "nonce": "0x0", + "address": "0x01208723ba84e15da2e71656544a2963b0c06d40" + } + }, + "transactions": [], + "provider": { + "type": "testnet" + }, + "selectedAccount": "0x01208723ba84e15da2e71656544a2963b0c06d40", + "selectedAccountTxList": [], + "seedWords": null, + "isDisclaimerConfirmed": true, + "unconfMsgs": {}, + "messages": [], + "shapeShiftTxList": [], + "keyringTypes": [ + "Simple Key Pair", + "HD Key Tree" + ], + "keyrings": [ + { + "type": "HD Key Tree", + "accounts": [ + "01208723ba84e15da2e71656544a2963b0c06d40" + ] + } + ], + "lostAccounts": [] + }, + "appState": { + "menuOpen": false, + "currentView": { + "name": "import-menu" + }, + "accountDetail": { + "subview": "transactions" + }, + "transForward": true, + "isLoading": false, + "warning": null + }, + "identities": {} +} \ No newline at end of file diff --git a/development/states/new-account.json b/development/states/new-account.json new file mode 100644 index 000000000..8c9be3654 --- /dev/null +++ b/development/states/new-account.json @@ -0,0 +1,66 @@ +{ + "metamask": { + "isInitialized": true, + "isUnlocked": true, + "rpcTarget": "https://rawtestrpc.metamask.io/", + "identities": { + "0xa6ef573d60594731178b7f85d80da13cc2af52dd": { + "address": "0xa6ef573d60594731178b7f85d80da13cc2af52dd", + "name": "Dan! 1" + }, + "0xf9f52e84ad2c9122caa87478d27041ddaa215666": { + "address": "0xf9f52e84ad2c9122caa87478d27041ddaa215666", + "name": "Account 2" + } + }, + "unconfTxs": {}, + "currentFiat": "USD", + "conversionRate": 10.92067835, + "conversionDate": 1478282884, + "network": null, + "accounts": { + "0xa6ef573d60594731178b7f85d80da13cc2af52dd": { + "balance": "0x00", + "nonce": "0x100000", + "code": "0x", + "address": "0xa6ef573d60594731178b7f85d80da13cc2af52dd" + }, + "0xf9f52e84ad2c9122caa87478d27041ddaa215666": { + "balance": "0x00", + "nonce": "0x100000", + "code": "0x", + "address": "0xf9f52e84ad2c9122caa87478d27041ddaa215666" + } + }, + "transactions": [], + "provider": { + "type": "testnet" + }, + "selectedAccount": "0xa6ef573d60594731178b7f85d80da13cc2af52dd", + "isConfirmed": true, + "unconfMsgs": {}, + "messages": [], + "selectedAddress": "0xa6ef573d60594731178b7f85d80da13cc2af52dd", + "shapeShiftTxList": [], + "keyringTypes": [ + "Simple Key Pair", + "HD Key Tree" + ] + }, + "appState": { + "menuOpen": false, + "currentView": { + "name": "new-account" + }, + "accountDetail": { + "subview": "transactions" + }, + "transForward": true, + "isLoading": false, + "warning": null, + "forgottenPassword": null, + "detailView": {}, + "scrollToBottom": false + }, + "identities": {} +} \ No newline at end of file diff --git a/notices/notice_0.md b/notices/notice_0.md deleted file mode 100644 index 1b2d5d018..000000000 --- a/notices/notice_0.md +++ /dev/null @@ -1,12 +0,0 @@ -Due to [recent events](https://blog.ethereum.org/2016/11/20/from-morden-to-ropsten/), MetaMask is now deprecating support for the Morden Test Network. - -Users will still be able to access Morden through a locally hosted node, but we will no longer be providing hosted access to this network through [Infura](http://infura.io/). - -Please use the new Ropsten Network as your new default test network. - -You can fund your Ropsten account using the buy button on your account page. - -Best wishes! - -The MetaMask Team - diff --git a/package.json b/package.json index 954f5a10e..25a2abc86 100644 --- a/package.json +++ b/package.json @@ -84,19 +84,22 @@ "react-hyperscript": "^2.2.2", "react-markdown": "^2.3.0", "react-redux": "^4.4.5", + "react-select": "^1.0.0-rc.2", + "react-simple-file-input": "^1.0.0", "react-tooltip-component": "^0.3.0", "readable-stream": "^2.1.2", "redux": "^3.0.5", "redux-logger": "^2.3.1", "redux-thunk": "^1.0.2", "sandwich-expando": "^1.0.5", + "semaphore": "^1.0.5", "textarea-caret": "^3.0.1", "three.js": "^0.73.2", "through2": "^2.0.1", "valid-url": "^1.0.9", "vreme": "^3.0.2", "web3": "0.17.0-beta", - "web3-provider-engine": "^8.2.0", + "web3-provider-engine": "^8.4.0", "web3-stream-provider": "^2.0.6", "xtend": "^4.0.1" }, diff --git a/test/integration/lib/first-time.js b/test/integration/lib/first-time.js index 1811ccbd4..777fcbb7e 100644 --- a/test/integration/lib/first-time.js +++ b/test/integration/lib/first-time.js @@ -66,7 +66,8 @@ QUnit.test('agree to terms', function (assert) { }).then(function() { var sandwich = app.find('.menu-droppo')[0] - var lock = sandwich.children[2] + var children = sandwich.children + var lock = children[children.length - 2] assert.ok(lock, 'Lock menu item found') lock.click() diff --git a/test/unit/explorer-link-test.js b/test/unit/explorer-link-test.js index 961b400fd..8aa58bff9 100644 --- a/test/unit/explorer-link-test.js +++ b/test/unit/explorer-link-test.js @@ -4,7 +4,7 @@ var linkGen = require('../../ui/lib/explorer-link') describe('explorer-link', function() { it('adds testnet prefix to morden test network', function() { - var result = linkGen('hash', '2') + var result = linkGen('hash', '3') assert.notEqual(result.indexOf('testnet'), -1, 'testnet injected') }) diff --git a/test/unit/keyrings/simple-test.js b/test/unit/keyrings/simple-test.js index 979abdb69..77eeb834c 100644 --- a/test/unit/keyrings/simple-test.js +++ b/test/unit/keyrings/simple-test.js @@ -1,5 +1,6 @@ const assert = require('assert') const extend = require('xtend') +const ethUtil = require('ethereumjs-util') const SimpleKeyring = require('../../../app/scripts/keyrings/simple') const TYPE_STR = 'Simple Key Pair' @@ -48,6 +49,24 @@ describe('simple-keyring', function() { }) }) + describe('#signMessage', function() { + const address = '0x9858e7d8b79fc3e6d989636721584498926da38a' + const message = '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0' + const privateKey = '0x7dd98753d7b4394095de7d176c58128e2ed6ee600abe97c9f6d9fd65015d9b18' + const expectedResult = '0x28fcb6768e5110144a55b2e6ce9d1ea5a58103033632d272d2b5cf506906f7941a00b539383fd872109633d8c71c404e13dba87bc84166ee31b0e36061a69e161c' + + it('passes the dennis test', function(done) { + keyring.deserialize([ privateKey ]) + .then(() => { + return keyring.signMessage(address, message) + }) + .then((result) => { + assert.equal(result, expectedResult) + done() + }) + }) + }) + describe('#addAccounts', function() { describe('with no arguments', function() { it('creates a single wallet', function() { @@ -72,14 +91,10 @@ describe('simple-keyring', function() { it('calls getAddress on each wallet', function(done) { // Push a mock wallet - const desiredOutput = 'foo' + const desiredOutput = '0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761' keyring.wallets.push({ getAddress() { - return { - toString() { - return desiredOutput - } - } + return ethUtil.toBuffer(desiredOutput) } }) diff --git a/test/unit/metamask-controller-test.js b/test/unit/metamask-controller-test.js index e648ebd1d..24d9ddd67 100644 --- a/test/unit/metamask-controller-test.js +++ b/test/unit/metamask-controller-test.js @@ -27,24 +27,6 @@ describe('MetaMaskController', function() { this.sinon.restore() }) - describe('#enforceTxValidations', function () { - it('returns null for positive values', function() { - var sample = { - value: '0x01' - } - var res = controller.enforceTxValidations(sample) - assert.equal(res, null, 'no error') - }) - - - it('returns error for negative values', function() { - var sample = { - value: '-0x01' - } - var res = controller.enforceTxValidations(sample) - assert.ok(res, 'error') - }) - }) }) diff --git a/test/unit/notice-controller-test.js b/test/unit/notice-controller-test.js index 4aa4c8e7b..cf00daeba 100644 --- a/test/unit/notice-controller-test.js +++ b/test/unit/notice-controller-test.js @@ -5,13 +5,14 @@ const nock = require('nock') const configManagerGen = require('../lib/mock-config-manager') const NoticeController = require('../../app/scripts/notice-controller') const STORAGE_KEY = 'metamask-persistance-key' -// Hacking localStorage support into JSDom -window.localStorage = {} describe('notice-controller', function() { var noticeController beforeEach(function() { + // simple localStorage polyfill + window.localStorage = {} + if (window.localStorage.clear) window.localStorage.clear() let configManager = configManagerGen() noticeController = new NoticeController({ configManager: configManager, diff --git a/test/unit/tx-manager-test.js b/test/unit/tx-manager-test.js index be16facad..a66003f85 100644 --- a/test/unit/tx-manager-test.js +++ b/test/unit/tx-manager-test.js @@ -15,6 +15,28 @@ describe('Transaction Manager', function() { provider: "testnet", txHistoryLimit: 10, blockTracker: new EventEmitter(), + getNetwork: function(){ return 'unit test' } + }) + }) + + describe('#validateTxParams', function () { + it('returns null for positive values', function() { + var sample = { + value: '0x01' + } + var res = txManager.txProviderUtils.validateTxParams(sample, (err) => { + assert.equal(err, null, 'no error') + }) + }) + + + it('returns error for negative values', function() { + var sample = { + value: '-0x01' + } + var res = txManager.txProviderUtils.validateTxParams(sample, (err) => { + assert.ok(err, 'error') + }) }) }) @@ -31,7 +53,7 @@ describe('Transaction Manager', function() { describe('#_saveTxList', function() { it('saves the submitted data to the tx list', function() { - var target = [{ foo: 'bar' }] + var target = [{ foo: 'bar', metamaskNetworkId: 'unit test' }] txManager._saveTxList(target) var result = txManager.getTxList() assert.equal(result[0].foo, 'bar') @@ -40,7 +62,7 @@ describe('Transaction Manager', function() { describe('#addTx', function() { it('adds a tx returned in getTxList', function() { - var tx = { id: 1, status: 'confirmed',} + var tx = { id: 1, status: 'confirmed', metamaskNetworkId: 'unit test' } txManager.addTx(tx, onTxDoneCb) var result = txManager.getTxList() assert.ok(Array.isArray(result)) @@ -51,7 +73,7 @@ describe('Transaction Manager', function() { it('cuts off early txs beyond a limit', function() { const limit = txManager.txHistoryLimit for (let i = 0; i < limit + 1; i++) { - let tx = { id: i, time: new Date(), status: 'confirmed'} + let tx = { id: i, time: new Date(), status: 'confirmed', metamaskNetworkId: 'unit test' } txManager.addTx(tx, onTxDoneCb) } var result = txManager.getTxList() @@ -59,10 +81,10 @@ describe('Transaction Manager', function() { assert.equal(result[0].id, 1, 'early txs truncted') }) - it('cuts off early txs beyond a limit weather or not it is confirmed or rejected', function() { + it('cuts off early txs beyond a limit whether or not it is confirmed or rejected', function() { const limit = txManager.txHistoryLimit for (let i = 0; i < limit + 1; i++) { - let tx = { id: i, time: new Date(), status: 'rejected'} + let tx = { id: i, time: new Date(), status: 'rejected', metamaskNetworkId: 'unit test' } txManager.addTx(tx, onTxDoneCb) } var result = txManager.getTxList() @@ -71,11 +93,11 @@ describe('Transaction Manager', function() { }) it('cuts off early txs beyond a limit but does not cut unapproved txs', function() { - var unconfirmedTx = { id: 0, time: new Date(), status: 'unapproved'} + var unconfirmedTx = { id: 0, time: new Date(), status: 'unapproved', metamaskNetworkId: 'unit test' } txManager.addTx(unconfirmedTx, onTxDoneCb) const limit = txManager.txHistoryLimit for (let i = 1; i < limit + 1; i++) { - let tx = { id: i, time: new Date(), status: 'confirmed'} + let tx = { id: i, time: new Date(), status: 'confirmed', metamaskNetworkId: 'unit test' } txManager.addTx(tx, onTxDoneCb) } var result = txManager.getTxList() @@ -88,7 +110,7 @@ describe('Transaction Manager', function() { describe('#setTxStatusSigned', function() { it('sets the tx status to signed', function() { - var tx = { id: 1, status: 'unapproved' } + var tx = { id: 1, status: 'unapproved', metamaskNetworkId: 'unit test' } txManager.addTx(tx, onTxDoneCb) txManager.setTxStatusSigned(1) var result = txManager.getTxList() @@ -99,20 +121,21 @@ describe('Transaction Manager', function() { it('should emit a signed event to signal the exciton of callback', (done) => { this.timeout(10000) - var tx = { id: 1, status: 'unapproved' } - let onTxDoneCb = function (err, txId) { + var tx = { id: 1, status: 'unapproved', metamaskNetworkId: 'unit test' } + let onTxDoneCb = function () { assert(true, 'event listener has been triggered and onTxDoneCb executed') done() } - txManager.addTx(tx, onTxDoneCb) + txManager.addTx(tx) + txManager.on('1:signed', onTxDoneCb) txManager.setTxStatusSigned(1) }) }) describe('#setTxStatusRejected', function() { it('sets the tx status to rejected', function() { - var tx = { id: 1, status: 'unapproved' } - txManager.addTx(tx, onTxDoneCb) + var tx = { id: 1, status: 'unapproved', metamaskNetworkId: 'unit test' } + txManager.addTx(tx) txManager.setTxStatusRejected(1) var result = txManager.getTxList() assert.ok(Array.isArray(result)) @@ -122,12 +145,13 @@ describe('Transaction Manager', function() { it('should emit a rejected event to signal the exciton of callback', (done) => { this.timeout(10000) - var tx = { id: 1, status: 'unapproved' } + var tx = { id: 1, status: 'unapproved', metamaskNetworkId: 'unit test' } + txManager.addTx(tx) let onTxDoneCb = function (err, txId) { assert(true, 'event listener has been triggered and onTxDoneCb executed') done() } - txManager.addTx(tx, onTxDoneCb) + txManager.on('1:rejected', onTxDoneCb) txManager.setTxStatusRejected(1) }) @@ -135,9 +159,9 @@ describe('Transaction Manager', function() { describe('#updateTx', function() { it('replaces the tx with the same id', function() { - txManager.addTx({ id: '1', status: 'unapproved' }, onTxDoneCb) - txManager.addTx({ id: '2', status: 'confirmed' }, onTxDoneCb) - txManager.updateTx({ id: '1', status: 'blah', hash: 'foo' }) + txManager.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: 'unit test' }, onTxDoneCb) + txManager.addTx({ id: '2', status: 'confirmed', metamaskNetworkId: 'unit test' }, onTxDoneCb) + txManager.updateTx({ id: '1', status: 'blah', hash: 'foo', metamaskNetworkId: 'unit test' }) var result = txManager.getTx('1') assert.equal(result.hash, 'foo') }) @@ -145,8 +169,8 @@ describe('Transaction Manager', function() { describe('#getUnapprovedTxList', function() { it('returns unapproved txs in a hash', function() { - txManager.addTx({ id: '1', status: 'unapproved' }, onTxDoneCb) - txManager.addTx({ id: '2', status: 'confirmed' }, onTxDoneCb) + txManager.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: 'unit test' }, onTxDoneCb) + txManager.addTx({ id: '2', status: 'confirmed', metamaskNetworkId: 'unit test' }, onTxDoneCb) let result = txManager.getUnapprovedTxList() assert.equal(typeof result, 'object') assert.equal(result['1'].status, 'unapproved') @@ -156,8 +180,8 @@ describe('Transaction Manager', function() { describe('#getTx', function() { it('returns a tx with the requested id', function() { - txManager.addTx({ id: '1', status: 'unapproved' }, onTxDoneCb) - txManager.addTx({ id: '2', status: 'confirmed' }, onTxDoneCb) + txManager.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: 'unit test' }, onTxDoneCb) + txManager.addTx({ id: '2', status: 'confirmed', metamaskNetworkId: 'unit test' }, onTxDoneCb) assert.equal(txManager.getTx('1').status, 'unapproved') assert.equal(txManager.getTx('2').status, 'confirmed') }) @@ -171,6 +195,7 @@ describe('Transaction Manager', function() { let everyOther = i % 2 txManager.addTx({ id: i, status: everyOther ? 'unapproved' : 'confirmed', + metamaskNetworkId: 'unit test', txParams: { from: everyOther ? 'foop' : 'zoop', to: everyOther ? 'zoop' : 'foop', diff --git a/ui/app/account-detail.js b/ui/app/account-detail.js index c41ba61fd..7a0c599ba 100644 --- a/ui/app/account-detail.js +++ b/ui/app/account-detail.js @@ -26,11 +26,10 @@ function mapStateToProps (state) { accounts: state.metamask.accounts, address: state.metamask.selectedAccount, accountDetail: state.appState.accountDetail, - transactions: state.metamask.transactions, network: state.metamask.network, - unconfTxs: valuesFor(state.metamask.unconfTxs), unconfMsgs: valuesFor(state.metamask.unconfMsgs), shapeShiftTxList: state.metamask.shapeShiftTxList, + transactions: state.metamask.selectedAccountTxList || [], } } @@ -248,20 +247,10 @@ AccountDetailScreen.prototype.subview = function () { } AccountDetailScreen.prototype.transactionList = function () { - const { transactions, unconfTxs, unconfMsgs, address, network, shapeShiftTxList } = this.props - - var txsToRender = transactions.concat(unconfTxs) - // only transactions that are from the current address - .filter(tx => tx.txParams.from === address) - // only transactions that are on the current network - .filter(tx => tx.txParams.metamaskNetworkId === network) - // sort by recency - .sort((a, b) => b.time - a.time) - + const {transactions, unconfMsgs, address, network, shapeShiftTxList } = this.props return h(TransactionList, { - txsToRender, + transactions: transactions.sort((a, b) => b.time - a.time), network, - unconfTxs, unconfMsgs, address, shapeShiftTxList, diff --git a/ui/app/accounts/import/index.js b/ui/app/accounts/import/index.js new file mode 100644 index 000000000..96350852a --- /dev/null +++ b/ui/app/accounts/import/index.js @@ -0,0 +1,91 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +import Select from 'react-select' + +// Subviews +const JsonImportView = require('./json.js') +const PrivateKeyImportView = require('./private-key.js') + +const menuItems = [ + 'Private Key', + 'JSON File', +] + +module.exports = connect(mapStateToProps)(AccountImportSubview) + +function mapStateToProps (state) { + return { + menuItems, + } +} + +inherits(AccountImportSubview, Component) +function AccountImportSubview () { + Component.call(this) +} + +AccountImportSubview.prototype.render = function () { + const props = this.props + const state = this.state || {} + const { menuItems } = props + const { type } = state + + return ( + h('div', { + style: { + }, + }, [ + h('div', { + style: { + padding: '10px', + color: 'rgb(174, 174, 174)', + }, + }, [ + + h('h3', { style: { padding: '3px' } }, 'SELECT TYPE'), + + h('style', ` + .has-value.Select--single > .Select-control .Select-value .Select-value-label, .Select-value-label { + color: rgb(174,174,174); + } + `), + + h(Select, { + name: 'import-type-select', + clearable: false, + value: type || menuItems[0], + options: menuItems.map((type) => { + return { + value: type, + label: type, + } + }), + onChange: (opt) => { + this.setState({ type: opt.value }) + }, + }), + ]), + + this.renderImportView(), + ]) + ) +} + +AccountImportSubview.prototype.renderImportView = function() { + const props = this.props + const state = this.state || {} + const { type } = state + const { menuItems } = props + const current = type || menuItems[0] + + switch (current) { + case 'Private Key': + return h(PrivateKeyImportView) + case 'JSON File': + return h(JsonImportView) + default: + return h(JsonImportView) + } +} diff --git a/ui/app/accounts/import/json.js b/ui/app/accounts/import/json.js new file mode 100644 index 000000000..1c2b331d4 --- /dev/null +++ b/ui/app/accounts/import/json.js @@ -0,0 +1,98 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const actions = require('../../actions') +const FileInput = require('react-simple-file-input').default + +module.exports = connect(mapStateToProps)(JsonImportSubview) + +function mapStateToProps (state) { + return { + error: state.appState.warning, + } +} + +inherits(JsonImportSubview, Component) +function JsonImportSubview () { + Component.call(this) +} + +JsonImportSubview.prototype.render = function () { + const { error } = this.props + + return ( + h('div', { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '5px 15px 0px 15px', + }, + }, [ + + h('p', 'Used by a variety of different clients'), + + h(FileInput, { + readAs: 'text', + onLoad: this.onLoad.bind(this), + style: { + margin: '20px 0px 12px 20px', + fontSize: '15px', + }, + }), + + h('input.large-input.letter-spacey', { + type: 'password', + placeholder: 'Enter password', + id: 'json-password-box', + onKeyPress: this.createKeyringOnEnter.bind(this), + style: { + width: 260, + marginTop: 12, + }, + }), + + h('button.primary', { + onClick: this.createNewKeychain.bind(this), + style: { + margin: 12, + }, + }, 'Import'), + + error ? h('span.warning', error) : null, + ]) + ) +} + +JsonImportSubview.prototype.onLoad = function (event, file) { + this.setState({file: file, fileContents: event.target.result}) +} + +JsonImportSubview.prototype.createKeyringOnEnter = function (event) { + if (event.key === 'Enter') { + event.preventDefault() + this.createNewKeychain() + } +} + +JsonImportSubview.prototype.createNewKeychain = function () { + const state = this.state + const { fileContents } = state + + if (!fileContents) { + const message = 'You must select a file to import.' + return this.props.dispatch(actions.displayWarning(message)) + } + + const passwordInput = document.getElementById('json-password-box') + const password = passwordInput.value + + if (!password) { + const message = 'You must enter a password for the selected file.' + return this.props.dispatch(actions.displayWarning(message)) + } + + this.props.dispatch(actions.importNewAccount('JSON File', [ fileContents, password ])) +} + diff --git a/ui/app/accounts/import/private-key.js b/ui/app/accounts/import/private-key.js new file mode 100644 index 000000000..b139a0374 --- /dev/null +++ b/ui/app/accounts/import/private-key.js @@ -0,0 +1,68 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const actions = require('../../actions') + +module.exports = connect(mapStateToProps)(PrivateKeyImportView) + +function mapStateToProps (state) { + return { + error: state.appState.warning, + } +} + +inherits(PrivateKeyImportView, Component) +function PrivateKeyImportView () { + Component.call(this) +} + +PrivateKeyImportView.prototype.render = function () { + const { error } = this.props + + return ( + h('div', { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '5px 15px 0px 15px', + }, + }, [ + h('span', 'Paste your private key string here'), + + h('input.large-input.letter-spacey', { + type: 'password', + id: 'private-key-box', + onKeyPress: this.createKeyringOnEnter.bind(this), + style: { + width: 260, + marginTop: 12, + }, + }), + + h('button.primary', { + onClick: this.createNewKeychain.bind(this), + style: { + margin: 12, + }, + }, 'Import'), + + error ? h('span.warning', error) : null, + ]) + ) +} + +PrivateKeyImportView.prototype.createKeyringOnEnter = function (event) { + if (event.key === 'Enter') { + event.preventDefault() + this.createNewKeychain() + } +} + +PrivateKeyImportView.prototype.createNewKeychain = function () { + const input = document.getElementById('private-key-box') + const privateKey = input.value + this.props.dispatch(actions.importNewAccount('Private Key', [ privateKey ])) +} + diff --git a/ui/app/accounts/import/seed.js b/ui/app/accounts/import/seed.js new file mode 100644 index 000000000..b4a7c0afa --- /dev/null +++ b/ui/app/accounts/import/seed.js @@ -0,0 +1,30 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect + +module.exports = connect(mapStateToProps)(SeedImportSubview) + +function mapStateToProps (state) { + return {} +} + +inherits(SeedImportSubview, Component) +function SeedImportSubview () { + Component.call(this) +} + +SeedImportSubview.prototype.render = function () { + return ( + h('div', { + style: { + }, + }, [ + `Paste your seed phrase here!`, + h('textarea'), + h('br'), + h('button', 'Submit'), + ]) + ) +} + diff --git a/ui/app/accounts/index.js b/ui/app/accounts/index.js index edb15eafe..e6f376735 100644 --- a/ui/app/accounts/index.js +++ b/ui/app/accounts/index.js @@ -73,7 +73,8 @@ AccountsScreen.prototype.render = function () { const simpleAddress = identity.address.substring(2).toLowerCase() const keyring = keyrings.find((kr) => { - return kr.accounts.includes(simpleAddress) + return kr.accounts.includes(simpleAddress) || + kr.accounts.includes(identity.address) }) return h(AccountListItem, { @@ -154,6 +155,13 @@ AccountsScreen.prototype.addNewAccount = function () { this.props.dispatch(actions.addNewAccount(0)) } +/* An optional view proposed in this design: + * https://consensys.quip.com/zZVrAysM5znY +AccountsScreen.prototype.addNewAccount = function () { + this.props.dispatch(actions.navigateToNewAccountScreen()) +} +*/ + AccountsScreen.prototype.goHome = function () { this.props.dispatch(actions.goHome()) } diff --git a/ui/app/actions.js b/ui/app/actions.js index 5a3968f82..bf3617310 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -32,16 +32,21 @@ var actions = { SHOW_INIT_MENU: 'SHOW_INIT_MENU', SHOW_NEW_VAULT_SEED: 'SHOW_NEW_VAULT_SEED', SHOW_INFO_PAGE: 'SHOW_INFO_PAGE', + SHOW_IMPORT_PAGE: 'SHOW_IMPORT_PAGE', unlockMetamask: unlockMetamask, unlockFailed: unlockFailed, showCreateVault: showCreateVault, showRestoreVault: showRestoreVault, showInitializeMenu: showInitializeMenu, + showImportPage, createNewVaultAndKeychain: createNewVaultAndKeychain, createNewVaultAndRestore: createNewVaultAndRestore, createNewVaultInProgress: createNewVaultInProgress, addNewKeyring, + importNewAccount, addNewAccount, + NEW_ACCOUNT_SCREEN: 'NEW_ACCOUNT_SCREEN', + navigateToNewAccountScreen, showNewVaultSeed: showNewVaultSeed, showInfoPage: showInfoPage, // seed recovery actions @@ -249,7 +254,36 @@ function requestRevealSeed (password) { } function addNewKeyring (type, opts) { - return callBackgroundThenUpdate(background.addNewKeyring, type, opts) + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + background.addNewKeyring(type, opts, (err, newState) => { + dispatch(actions.hideLoadingIndication()) + if (err) return dispatch(actions.displayWarning(err.message)) + dispatch(actions.updateMetamaskState(newState)) + dispatch(actions.showAccountsPage()) + }) + } +} + +function importNewAccount (strategy, args) { + return (dispatch) => { + dispatch(actions.showLoadingIndication('This may take a while, be patient.')) + background.importAccountWithStrategy(strategy, args, (err, newState) => { + dispatch(actions.hideLoadingIndication()) + if (err) return dispatch(actions.displayWarning(err.message)) + dispatch(actions.updateMetamaskState(newState)) + dispatch({ + type: actions.SHOW_ACCOUNT_DETAIL, + value: newState.selectedAccount, + }) + }) + } +} + +function navigateToNewAccountScreen() { + return { + type: this.NEW_ACCOUNT_SCREEN, + } } function addNewAccount (ringNumber = 0) { @@ -376,6 +410,12 @@ function showInitializeMenu () { } } +function showImportPage () { + return { + type: actions.SHOW_IMPORT_PAGE, + } +} + function agreeToDisclaimer () { return (dispatch) => { dispatch(this.showLoadingIndication()) @@ -590,9 +630,10 @@ function useEtherscanProvider () { } } -function showLoadingIndication () { +function showLoadingIndication (message) { return { type: actions.SHOW_LOADING, + value: message, } } diff --git a/ui/app/app.js b/ui/app/app.js index 9efe95874..d8dedd397 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -20,6 +20,7 @@ const NoticeScreen = require('./components/notice') const generateLostAccountsNotice = require('../lib/lost-accounts-notice') // other views const ConfigScreen = require('./config') +const Import = require('./accounts/import') const InfoScreen = require('./info') const LoadingIndicator = require('./components/loading') const SandwichExpando = require('sandwich-expando') @@ -42,6 +43,7 @@ function mapStateToProps (state) { return { // state from plugin isLoading: state.appState.isLoading, + loadingMessage: state.appState.loadingMessage, isDisclaimerConfirmed: state.metamask.isDisclaimerConfirmed, noActiveNotices: state.metamask.noActiveNotices, isInitialized: state.metamask.isInitialized, @@ -63,7 +65,7 @@ function mapStateToProps (state) { App.prototype.render = function () { var props = this.props - const { isLoading, transForward } = props + const { isLoading, loadingMessage, transForward } = props return ( @@ -75,7 +77,7 @@ App.prototype.render = function () { }, }, [ - h(LoadingIndicator, { isLoading }), + h(LoadingIndicator, { isLoading, loadingMessage }), // app bar this.renderAppBar(), @@ -304,6 +306,13 @@ App.prototype.renderDropdown = function () { icon: h('i.fa.fa-gear.fa-lg'), }), + h(DropMenuItem, { + label: 'Import Account', + closeMenu: () => this.setState({ isMainMenuOpen: !isOpen }), + action: () => this.props.dispatch(actions.showImportPage()), + icon: h('i.fa.fa-arrow-circle-o-up.fa-lg'), + }), + h(DropMenuItem, { label: 'Lock', closeMenu: () => this.setState({ isMainMenuOpen: !isOpen }), @@ -411,6 +420,9 @@ App.prototype.renderPrimary = function () { case 'config': return h(ConfigScreen, {key: 'config'}) + case 'import-menu': + return h(Import, {key: 'import-menu'}) + case 'reveal-seed-conf': return h(RevealSeedConfirmation, {key: 'reveal-seed-conf'}) diff --git a/ui/app/components/buy-button-subview.js b/ui/app/components/buy-button-subview.js index 35eda647e..afda5bf59 100644 --- a/ui/app/components/buy-button-subview.js +++ b/ui/app/components/buy-button-subview.js @@ -7,6 +7,7 @@ const CoinbaseForm = require('./coinbase-form') const ShapeshiftForm = require('./shapeshift-form') const extension = require('../../../app/scripts/lib/extension') const Loading = require('./loading') +const TabBar = require('./tab-bar') module.exports = connect(mapStateToProps)(BuyButtonSubview) @@ -29,7 +30,6 @@ function BuyButtonSubview () { BuyButtonSubview.prototype.render = function () { const props = this.props - const currentForm = props.buyView.formView const isLoading = props.isSubLoading return ( @@ -53,43 +53,53 @@ BuyButtonSubview.prototype.render = function () { h(Loading, { isLoading }), - h('h3.flex-row.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - paddingTop: '4px', - justifyContent: 'space-around', + h(TabBar, { + tabs: [ + { + content: [ + 'Coinbase', + h('a', { + onClick: (event) => this.navigateTo('https://github.com/MetaMask/faq/blob/master/COINBASE.md'), + }, [ + h('i.fa.fa-question-circle', { + style: { + margin: '0px 5px', + }, + }), + ]), + ], + key: 'coinbase', + }, + { + content: [ + 'Shapeshift', + h('a', { + href: 'https://github.com/MetaMask/faq/blob/master/COINBASE.md', + onClick: (event) => this.navigateTo('https://info.shapeshift.io/about'), + }, [ + h('i.fa.fa-question-circle', { + style: { + margin: '0px 5px', + }, + }), + ]), + ], + key: 'shapeshift', + }, + ], + defaultTab: 'coinbase', + tabSelected: (key) => { + switch (key) { + case 'coinbase': + props.dispatch(actions.coinBaseSubview()) + break + case 'shapeshift': + props.dispatch(actions.shapeShiftSubview(props.provider.type)) + break + } }, - }, [ - h(currentForm.coinbase ? '.activeForm' : '.inactiveForm.pointer', { - onClick: () => props.dispatch(actions.coinBaseSubview()), - }, 'Coinbase'), - h('a', { - onClick: (event) => this.navigateTo('https://github.com/MetaMask/faq/blob/master/COINBASE.md'), - }, [ - h('i.fa.fa-question-circle', { - style: { - position: 'relative', - right: '33px', - }, - }), - ]), - h(currentForm.shapeshift ? '.activeForm' : '.inactiveForm.pointer', { - onClick: () => props.dispatch(actions.shapeShiftSubview(props.provider.type)), - }, 'Shapeshift'), + }), - h('a', { - href: 'https://github.com/MetaMask/faq/blob/master/COINBASE.md', - onClick: (event) => this.navigateTo('https://info.shapeshift.io/about'), - }, [ - h('i.fa.fa-question-circle', { - style: { - position: 'relative', - right: '28px', - }, - }), - ]), - ]), this.formVersionSubview(), ]) ) diff --git a/ui/app/components/coinbase-form.js b/ui/app/components/coinbase-form.js index 693eb2ea8..430a3eead 100644 --- a/ui/app/components/coinbase-form.js +++ b/ui/app/components/coinbase-form.js @@ -72,7 +72,7 @@ CoinbaseForm.prototype.render = function () { lineHeight: '13px', }, }, - `there is a USD$ 5 a day max and a USD$ 50 + `there is a USD$ 15 a day max and a USD$ 50 dollar limit per the life time of an account without a coinbase account. A fee of 3.75% will be aplied to debit/credit cards.`), @@ -136,14 +136,14 @@ CoinbaseForm.prototype.renderLoading = function () { function isValidAmountforCoinBase (amount) { amount = parseFloat(amount) if (amount) { - if (amount <= 5 && amount > 0) { + if (amount <= 15 && amount > 0) { return { valid: true, } - } else if (amount > 5) { + } else if (amount > 15) { return { valid: false, - message: 'The amount can not be greater then $5', + message: 'The amount can not be greater then $15', } } else { return { diff --git a/ui/app/components/loading.js b/ui/app/components/loading.js index ae735894f..88dc535df 100644 --- a/ui/app/components/loading.js +++ b/ui/app/components/loading.js @@ -12,7 +12,7 @@ function LoadingIndicator () { } LoadingIndicator.prototype.render = function () { - var isLoading = this.props.isLoading + const { isLoading, loadingMessage } = this.props return ( h(ReactCSSTransitionGroup, { @@ -37,8 +37,14 @@ LoadingIndicator.prototype.render = function () { h('img', { src: 'images/loading.svg', }), + + showMessageIfAny(loadingMessage), ]) : null, ]) ) } +function showMessageIfAny (loadingMessage) { + if (!loadingMessage) return null + return h('span', loadingMessage) +} diff --git a/ui/app/components/pending-tx-details.js b/ui/app/components/pending-tx-details.js index 89472b221..286931f6f 100644 --- a/ui/app/components/pending-tx-details.js +++ b/ui/app/components/pending-tx-details.js @@ -7,8 +7,6 @@ const EthBalance = require('./eth-balance') const util = require('../util') const addressSummary = util.addressSummary const nameForAddress = require('../../lib/contract-namer') -const ethUtil = require('ethereumjs-util') -const BN = ethUtil.BN module.exports = PendingTxDetails @@ -29,15 +27,9 @@ PTXP.render = function () { var account = props.accounts[address] var balance = account ? account.balance : '0x0' - var gasMultiplier = txData.gasMultiplier - var gasCost = new BN(ethUtil.stripHexPrefix(txParams.gas || txData.estimatedGas), 16) - var gasPrice = new BN(ethUtil.stripHexPrefix(txParams.gasPrice || '0x4a817c800'), 16) - gasPrice = gasPrice.mul(new BN(gasMultiplier * 100), 10).div(new BN(100, 10)) - var txFee = gasCost.mul(gasPrice) - var txValue = new BN(ethUtil.stripHexPrefix(txParams.value || '0x0'), 16) - var maxCost = txValue.add(txFee) + var txFee = txData.txFee || '' + var maxCost = txData.maxCost || '' var dataLength = txParams.data ? (txParams.data.length - 2) / 2 : 0 - var imageify = props.imageifyIdenticons === undefined ? true : props.imageifyIdenticons return ( diff --git a/ui/app/components/tab-bar.js b/ui/app/components/tab-bar.js new file mode 100644 index 000000000..65078e0a4 --- /dev/null +++ b/ui/app/components/tab-bar.js @@ -0,0 +1,35 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits + +module.exports = TabBar + +inherits(TabBar, Component) +function TabBar () { + Component.call(this) +} + +TabBar.prototype.render = function () { + const props = this.props + const state = this.state || {} + const { tabs = [], defaultTab, tabSelected } = props + const { subview = defaultTab } = state + + return ( + h('.flex-row.space-around.text-transform-uppercase', { + style: { + background: '#EBEBEB', + color: '#AEAEAE', + paddingTop: '4px', + }, + }, tabs.map((tab) => { + const { key, content } = tab + return h(subview === key ? '.activeForm' : '.inactiveForm.pointer', { + onClick: () => { + this.setState({ subview: key }) + tabSelected(key) + }, + }, content) + })) + ) +} diff --git a/ui/app/components/transaction-list-item-icon.js b/ui/app/components/transaction-list-item-icon.js index 8b118b1d4..353401099 100644 --- a/ui/app/components/transaction-list-item-icon.js +++ b/ui/app/components/transaction-list-item-icon.js @@ -13,13 +13,40 @@ function TransactionIcon () { TransactionIcon.prototype.render = function () { const { transaction, txParams, isMsg } = this.props + switch (transaction.status) { + case 'unapproved': + return h('.unapproved-tx', { + style: { + width: '24px', + height: '24px', + background: '#4dffff', + border: 'solid', + borderColor: '#AEAEAE', + borderWidth: '0.5px', + borderRadius: '13px', + }, + }) - if (transaction.status === 'rejected') { - return h('i.fa.fa-exclamation-triangle.fa-lg.warning', { - style: { - width: '24px', - }, - }) + case 'rejected': + return h('i.fa.fa-exclamation-triangle.fa-lg.warning', { + style: { + width: '24px', + }, + }) + + case 'failed': + return h('i.fa.fa-exclamation-triangle.fa-lg.error', { + style: { + width: '24px', + }, + }) + + case 'submitted': + return h('i.fa.fa-ellipsis-h', { + style: { + fontSize: '27px', + }, + }) } if (isMsg) { diff --git a/ui/app/components/transaction-list-item.js b/ui/app/components/transaction-list-item.js index bb685abda..95e850264 100644 --- a/ui/app/components/transaction-list-item.js +++ b/ui/app/components/transaction-list-item.js @@ -8,6 +8,7 @@ const explorerLink = require('../../lib/explorer-link') const CopyButton = require('./copyButton') const vreme = new (require('vreme')) const extension = require('../../../app/scripts/lib/extension') +const Tooltip = require('./tooltip') const TransactionIcon = require('./transaction-list-item-icon') const ShiftListItem = require('./shift-list-item') @@ -27,7 +28,7 @@ TransactionListItem.prototype.render = function () { let isLinkable = false const numericNet = parseInt(network) - isLinkable = numericNet === 1 || numericNet === 2 + isLinkable = numericNet === 1 || numericNet === 3 var isMsg = ('msgParams' in transaction) var isTx = ('txParams' in transaction) @@ -41,7 +42,6 @@ TransactionListItem.prototype.render = function () { } const isClickable = ('hash' in transaction && isLinkable) || isPending - return ( h(`.transaction-list-item.flex-row.flex-space-between${isClickable ? '.pointer' : ''}`, { onClick: (event) => { @@ -59,11 +59,7 @@ TransactionListItem.prototype.render = function () { }, [ h('.identicon-wrapper.flex-column.flex-center.select-none', [ - transaction.status === 'unapproved' ? h('i.fa.fa-ellipsis-h', { - style: { - fontSize: '27px', - }, - }) : h('.pop-hover', { + h('.pop-hover', { onClick: (event) => { event.stopPropagation() if (!isTx || isPending) return @@ -139,7 +135,14 @@ function failIfFailed (transaction) { if (transaction.status === 'rejected') { return h('span.error', ' (Rejected)') } - if (transaction.status === 'failed') { - return h('span.error', ' (Failed)') + if (transaction.err) { + + return h(Tooltip, { + title: transaction.err.message, + position: 'bottom', + }, [ + h('span.error', ' (Failed)'), + ]) } + } diff --git a/ui/app/components/transaction-list.js b/ui/app/components/transaction-list.js index 7e1bedb05..b055ca9d5 100644 --- a/ui/app/components/transaction-list.js +++ b/ui/app/components/transaction-list.js @@ -13,12 +13,13 @@ function TransactionList () { } TransactionList.prototype.render = function () { - const { txsToRender, network, unconfMsgs } = this.props + const { transactions, network, unconfMsgs } = this.props + var shapeShiftTxList if (network === '1') { shapeShiftTxList = this.props.shapeShiftTxList } - const transactions = !shapeShiftTxList ? txsToRender.concat(unconfMsgs) : txsToRender.concat(unconfMsgs, shapeShiftTxList) + const txsToRender = !shapeShiftTxList ? transactions.concat(unconfMsgs) : transactions.concat(unconfMsgs, shapeShiftTxList) .sort((a, b) => b.time - a.time) return ( @@ -55,8 +56,8 @@ TransactionList.prototype.render = function () { }, }, [ - transactions.length - ? transactions.map((transaction, i) => { + txsToRender.length + ? txsToRender.map((transaction, i) => { let key switch (transaction.key) { case 'shapeshift': diff --git a/ui/app/conf-tx.js b/ui/app/conf-tx.js index 5a645022a..a6e03c3ed 100644 --- a/ui/app/conf-tx.js +++ b/ui/app/conf-tx.js @@ -41,11 +41,13 @@ ConfirmTxScreen.prototype.render = function () { var provider = state.provider var unconfTxs = state.unconfTxs var unconfMsgs = state.unconfMsgs + var unconfTxList = txHelper(unconfTxs, unconfMsgs, network) - var index = state.index !== undefined ? state.index : 0 - var txData = unconfTxList[index] || unconfTxList[0] || {} - var txParams = txData.txParams || {} + var index = state.index !== undefined && unconfTxList[index] ? state.index : 0 + var txData = unconfTxList[index] || {} + var txParams = txData.params || {} var isNotification = isPopupOrNotification() === 'notification' + if (unconfTxList.length === 0) return null return ( @@ -115,27 +117,24 @@ ConfirmTxScreen.prototype.render = function () { } function currentTxView (opts) { - if ('txParams' in opts.txData) { + const { txData } = opts + const { txParams, msgParams } = txData + + if (txParams) { // This is a pending transaction return h(PendingTx, opts) - } else if ('msgParams' in opts.txData) { + } else if (msgParams) { // This is a pending message to sign return h(PendingMsg, opts) } } ConfirmTxScreen.prototype.checkBalanceAgainstTx = function (txData) { + if (!txData.txParams) return false var state = this.props - - var txParams = txData.txParams || {} - var address = txParams.from || state.selectedAccount + var address = txData.txParams.from || state.selectedAccount var account = state.accounts[address] var balance = account ? account.balance : '0x0' - - var gasCost = new BN(ethUtil.stripHexPrefix(txParams.gas || txData.estimatedGas), 16) - var gasPrice = new BN(ethUtil.stripHexPrefix(txParams.gasPrice || '0x4a817c800'), 16) - var txFee = gasCost.mul(gasPrice) - var txValue = new BN(ethUtil.stripHexPrefix(txParams.value || '0x0'), 16) - var maxCost = txValue.add(txFee) + var maxCost = new BN(txData.maxCost) var balanceBn = new BN(ethUtil.stripHexPrefix(balance), 16) return maxCost.gt(balanceBn) diff --git a/ui/app/css/lib.css b/ui/app/css/lib.css index abbf8667e..a8df1d115 100644 --- a/ui/app/css/lib.css +++ b/ui/app/css/lib.css @@ -23,6 +23,14 @@ flex-direction: column; } +.space-between { + justify-content: space-between; +} + +.space-around { + justify-content: space-around; +} + .flex-column-bottom { display: flex; flex-direction: column-reverse; diff --git a/ui/app/info.js b/ui/app/info.js index cc753b2ea..e79580be4 100644 --- a/ui/app/info.js +++ b/ui/app/info.js @@ -110,7 +110,7 @@ InfoScreen.prototype.render = function () { onClick (event) { this.navigateTo(event.target.href) }, }, [ h('img.icon-size', { - src: manifest.icons[128], + src: manifest.icons['128'], style: { filter: 'grayscale(100%)', /* IE6-9 */ WebkitFilter: 'grayscale(100%)', /* Microsoft Edge and Firefox 35+ */ diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js index 65a3dba49..6a2c93f78 100644 --- a/ui/app/reducers/app.js +++ b/ui/app/reducers/app.js @@ -99,6 +99,14 @@ function reduceApp (state, action) { transForward: action.value, }) + case actions.SHOW_IMPORT_PAGE: + return extend(appState, { + currentView: { + name: 'import-menu', + }, + transForward: true, + }) + case actions.SHOW_INFO_PAGE: return extend(appState, { currentView: { @@ -128,6 +136,15 @@ function reduceApp (state, action) { isLoading: false, }) + case actions.NEW_ACCOUNT_SCREEN: + return extend(appState, { + currentView: { + name: 'new-account', + context: appState.currentView.context, + }, + transForward: true, + }) + case actions.SHOW_SEND_PAGE: return extend(appState, { currentView: { @@ -369,6 +386,7 @@ function reduceApp (state, action) { case actions.SHOW_LOADING: return extend(appState, { isLoading: true, + loadingMessage: action.value, }) case actions.HIDE_LOADING: @@ -446,7 +464,7 @@ function reduceApp (state, action) { }, buyView: { subview: 'buyForm', - amount: '5.00', + amount: '15.00', buyAddress: action.value, formView: { coinbase: true, diff --git a/ui/app/unlock.js b/ui/app/unlock.js index 19f5eaec2..1aee3c5d0 100644 --- a/ui/app/unlock.js +++ b/ui/app/unlock.js @@ -26,7 +26,7 @@ UnlockScreen.prototype.render = function () { const state = this.props const warning = state.warning return ( - h('.flex-column.hey-im-here', [ + h('.flex-column', [ h('.unlock-screen.flex-column.flex-center.flex-grow', [ h(Mascot, { diff --git a/ui/css.js b/ui/css.js index 01f317acd..043363cd7 100644 --- a/ui/css.js +++ b/ui/css.js @@ -10,6 +10,7 @@ var cssFiles = { 'index.css': fs.readFileSync(path.join(__dirname, '/app/css/index.css'), 'utf8'), 'transitions.css': fs.readFileSync(path.join(__dirname, '/app/css/transitions.css'), 'utf8'), 'react-tooltip-component.css': fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'react-tooltip-component', 'dist', 'react-tooltip-component.css'), 'utf8'), + 'react-css': fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'react-select', 'dist', 'react-select.css'), 'utf8'), } function bundleCss () { diff --git a/ui/lib/explorer-link.js b/ui/lib/explorer-link.js index 2993d1cf1..dc6be2984 100644 --- a/ui/lib/explorer-link.js +++ b/ui/lib/explorer-link.js @@ -5,7 +5,7 @@ module.exports = function (hash, network) { case 1: // main net prefix = '' break - case 2: // morden test net + case 3: // morden test net prefix = 'testnet.' break default: