Merge branch 'master' into transactionControllerRefractorPt3

feature/default_network_editable
frankiebee 7 years ago
commit 8ab23c713d
  1. 37
      CHANGELOG.md
  2. 1
      README.md
  3. 5
      app/manifest.json
  4. 8
      app/scripts/background.js
  5. 45
      app/scripts/contentscript.js
  6. 61
      app/scripts/controllers/balance.js
  7. 66
      app/scripts/controllers/computed-balances.js
  8. 6
      app/scripts/controllers/transactions.js
  9. 16
      app/scripts/keyring-controller.js
  10. 76
      app/scripts/lib/account-tracker.js
  11. 15
      app/scripts/lib/createLoggerMiddleware.js
  12. 9
      app/scripts/lib/createOriginMiddleware.js
  13. 13
      app/scripts/lib/createProviderMiddleware.js
  14. 83
      app/scripts/lib/inpage-provider.js
  15. 48
      app/scripts/lib/obj-multiplex.js
  16. 51
      app/scripts/lib/pending-balance-calculator.js
  17. 12
      app/scripts/lib/pending-tx-tracker.js
  18. 16
      app/scripts/lib/port-stream.js
  19. 22
      app/scripts/lib/stream-utils.js
  20. 1
      app/scripts/lib/tx-state-manager.js
  21. 125
      app/scripts/metamask-controller.js
  22. 17
      circle.yml
  23. 4
      development/index.html
  24. 4
      development/states/first-time.json
  25. 5
      development/test.html
  26. 0
      docs/add-to-firefox.md
  27. 92
      docs/porting_to_new_environment.md
  28. 3
      mascara/src/proxy.js
  29. 41
      mascara/src/ui.js
  30. 21
      mascara/test/index.html
  31. 119
      mascara/test/lib/first-time.js
  32. 12
      mascara/test/test-ui.js
  33. 13
      mascara/test/testem.yml
  34. 5
      mascara/test/window-load.js
  35. 18
      mock-dev.js
  36. 66
      package.json
  37. 59
      test/base.conf.js
  38. 8
      test/flat.conf.js
  39. 7
      test/integration/helpers.js
  40. 19
      test/integration/index.js
  41. 139
      test/integration/lib/first-time.js
  42. 4
      test/lib/mock-encryptor.js
  43. 17
      test/mascara.conf.js
  44. 5
      test/unit/components/pending-tx-test.js
  45. 7
      test/unit/keyring-controller-test.js
  46. 19
      test/unit/nonce-tracker-test.js
  47. 93
      test/unit/pending-balance-test.js
  48. 2
      test/unit/tx-controller-test.js
  49. 10
      testem.yml
  50. 4
      ui-dev.js
  51. 5
      ui/app/account-detail.js
  52. 11
      ui/app/actions.js
  53. 29
      ui/app/add-token.js
  54. 3
      ui/app/app.js
  55. 21
      ui/app/components/account-dropdowns.js
  56. 28
      ui/app/components/account-export.js
  57. 2
      ui/app/components/dropdown.js
  58. 11
      ui/app/components/network.js
  59. 57
      ui/app/components/pending-tx.js
  60. 2
      ui/app/components/tooltip.js
  61. 2
      ui/app/components/transaction-list-item-icon.js
  62. 2
      ui/app/components/transaction-list-item.js
  63. 15
      ui/app/conf-tx.js
  64. 7
      ui/app/config.js
  65. 5
      ui/app/css/lib.css
  66. 2
      ui/app/info.js
  67. 10
      ui/app/keychains/hd/create-vault-complete.js
  68. 5
      ui/app/send.js
  69. 2
      ui/app/unlock.js
  70. 23
      ui/app/util.js

@ -2,6 +2,43 @@
## Current Master
- Fix bug that could mis-render token balances when very small. (Not actually included in 3.9.9)
## 3.10.3 2017-9-21
- Fix bug where metamask-dapp connections are lost on rpc error
- Fix bug that would sometimes display transactions as failed that could be successfully mined.
## 3.10.2 2017-9-18
rollback to 3.10.0 due to bug
## 3.10.1 2017-9-18
- Add ability to export private keys as a file.
- Add ability to export seed words as a file.
- Changed state logs to a file download than a clipboard copy.
- Add specific error for failed recipient address checksum.
- Fixed a long standing memory leak associated with filters installed by dapps
- Fix link to support center.
- Fixed tooltip icon locations to avoid overflow.
- Warn users when a dapp proposes a high gas limit (90% of blockGasLimit or higher)
## 3.10.0 2017-9-11
- Readded loose keyring label back into the account list.
- Remove cryptonator from chrome permissions.
- Add info on token contract addresses.
- Add validation preventing users from inputting their own addresses as token tracking addresses.
- Added button to reject all transactions (thanks to davidp94! https://github.com/davidp94)
## 3.9.13 2017-9-8
- Changed the way we initialize the inpage provider to fix a bug affecting some developers.
## 3.9.12 2017-9-6
- Fix bug that prevented Web3 1.0 compatibility
- Make eth_sign deprecation warning less noisy
- Add useful link to eth_sign deprecation warning.
- Fix bug with network version serialization over synchronous RPC

@ -71,6 +71,7 @@ To write tests that will be run in the browser using QUnit, add your test files
- [How to live reload on local dependency changes](./docs/developing-on-deps.md)
- [How to add new networks to the Provider Menu](./docs/adding-new-networks.md)
- [How to manage notices that appear when the app starts up](./docs/notices.md)
- [How to port MetaMask to a new platform](./docs/porting_to_new_environment.md)
- [How to generate a visualization of this repository's development](./docs/development-visualization.md)
[1]: http://www.nomnoml.com/#view/%5B%3Cactor%3Euser%5D%0A%0A%5Bmetamask-ui%7C%0A%20%20%20%5Btools%7C%0A%20%20%20%20%20react%0A%20%20%20%20%20redux%0A%20%20%20%20%20thunk%0A%20%20%20%20%20ethUtils%0A%20%20%20%20%20jazzicon%0A%20%20%20%5D%0A%20%20%20%5Bcomponents%7C%0A%20%20%20%20%20app%0A%20%20%20%20%20account-detail%0A%20%20%20%20%20accounts%0A%20%20%20%20%20locked-screen%0A%20%20%20%20%20restore-vault%0A%20%20%20%20%20identicon%0A%20%20%20%20%20config%0A%20%20%20%20%20info%0A%20%20%20%5D%0A%20%20%20%5Breducers%7C%0A%20%20%20%20%20app%0A%20%20%20%20%20metamask%0A%20%20%20%20%20identities%0A%20%20%20%5D%0A%20%20%20%5Bactions%7C%0A%20%20%20%20%20%5BaccountManager%5D%0A%20%20%20%5D%0A%20%20%20%5Bcomponents%5D%3A-%3E%5Bactions%5D%0A%20%20%20%5Bactions%5D%3A-%3E%5Breducers%5D%0A%20%20%20%5Breducers%5D%3A-%3E%5Bcomponents%5D%0A%5D%0A%0A%5Bweb%20dapp%7C%0A%20%20%5Bui%20code%5D%0A%20%20%5Bweb3%5D%0A%20%20%5Bmetamask-inpage%5D%0A%20%20%0A%20%20%5B%3Cactor%3Eui%20developer%5D%0A%20%20%5Bui%20developer%5D-%3E%5Bui%20code%5D%0A%20%20%5Bui%20code%5D%3C-%3E%5Bweb3%5D%0A%20%20%5Bweb3%5D%3C-%3E%5Bmetamask-inpage%5D%0A%5D%0A%0A%5Bmetamask-background%7C%0A%20%20%5Bprovider-engine%5D%0A%20%20%5Bhooked%20wallet%20subprovider%5D%0A%20%20%5Bid%20store%5D%0A%20%20%0A%20%20%5Bprovider-engine%5D%3C-%3E%5Bhooked%20wallet%20subprovider%5D%0A%20%20%5Bhooked%20wallet%20subprovider%5D%3C-%3E%5Bid%20store%5D%0A%20%20%5Bconfig%20manager%7C%0A%20%20%20%20%5Brpc%20configuration%5D%0A%20%20%20%20%5Bencrypted%20keys%5D%0A%20%20%20%20%5Bwallet%20nicknames%5D%0A%20%20%5D%0A%20%20%0A%20%20%5Bprovider-engine%5D%3C-%5Bconfig%20manager%5D%0A%20%20%5Bid%20store%5D%3C-%3E%5Bconfig%20manager%5D%0A%5D%0A%0A%5Buser%5D%3C-%3E%5Bmetamask-ui%5D%0A%0A%5Buser%5D%3C%3A--%3A%3E%5Bweb%20dapp%5D%0A%0A%5Bmetamask-contentscript%7C%0A%20%20%5Bplugin%20restart%20detector%5D%0A%20%20%5Brpc%20passthrough%5D%0A%5D%0A%0A%5Brpc%20%7C%0A%20%20%5Bethereum%20blockchain%20%7C%0A%20%20%20%20%5Bcontracts%5D%0A%20%20%20%20%5Baccounts%5D%0A%20%20%5D%0A%5D%0A%0A%5Bweb%20dapp%5D%3C%3A--%3A%3E%5Bmetamask-contentscript%5D%0A%5Bmetamask-contentscript%5D%3C-%3E%5Bmetamask-background%5D%0A%5Bmetamask-background%5D%3C-%3E%5Bmetamask-ui%5D%0A%5Bmetamask-background%5D%3C-%3E%5Brpc%5D%0A

@ -1,7 +1,7 @@
{
"name": "MetaMask",
"short_name": "Metamask",
"version": "3.9.11",
"version": "3.10.3",
"manifest_version": 2,
"author": "https://metamask.io",
"description": "Ethereum Browser Extension",
@ -57,8 +57,7 @@
"permissions": [
"storage",
"clipboardWrite",
"http://localhost:8545/",
"https://api.cryptonator.com/"
"http://localhost:8545/"
],
"web_accessible_resources": [
"scripts/inpage.js"

@ -1,6 +1,8 @@
const urlUtil = require('url')
const endOfStream = require('end-of-stream')
const pipe = require('pump')
const log = require('loglevel')
const extension = require('extensionizer')
const LocalStorageStore = require('obs-store/lib/localStorage')
const storeTransform = require('obs-store/lib/transform')
const ExtensionPlatform = require('./platforms/extension')
@ -9,13 +11,11 @@ const migrations = require('./migrations/')
const PortStream = require('./lib/port-stream.js')
const NotificationManager = require('./lib/notification-manager.js')
const MetamaskController = require('./metamask-controller')
const extension = require('extensionizer')
const firstTimeState = require('./first-time-state')
const STORAGE_KEY = 'metamask-config'
const METAMASK_DEBUG = 'GULP_METAMASK_DEBUG'
const log = require('loglevel')
window.log = log
log.setDefaultLevel(METAMASK_DEBUG ? 'debug' : 'warn')
@ -29,12 +29,12 @@ let popupIsOpen = false
const diskStore = new LocalStorageStore({ storageKey: STORAGE_KEY })
// initialization flow
initialize().catch(console.error)
initialize().catch(log.error)
async function initialize () {
const initState = await loadStateFromPersistence()
await setupController(initState)
console.log('MetaMask initialization complete.')
log.debug('MetaMask initialization complete.')
}
//

@ -1,11 +1,12 @@
const fs = require('fs')
const path = require('path')
const pump = require('pump')
const LocalMessageDuplexStream = require('post-message-stream')
const PongStream = require('ping-pong-stream/pong')
const PortStream = require('./lib/port-stream.js')
const ObjectMultiplex = require('./lib/obj-multiplex')
const ObjectMultiplex = require('obj-multiplex')
const extension = require('extensionizer')
const PortStream = require('./lib/port-stream.js')
const fs = require('fs')
const path = require('path')
const inpageText = fs.readFileSync(path.join(__dirname, 'inpage.js')).toString()
// Eventually this streaming injection could be replaced with:
@ -50,22 +51,42 @@ function setupStreams () {
pageStream.pipe(pluginStream).pipe(pageStream)
// setup local multistream channels
const mx = ObjectMultiplex()
mx.on('error', console.error)
mx.pipe(pageStream).pipe(mx)
mx.pipe(pluginStream).pipe(mx)
const mux = new ObjectMultiplex()
pump(
mux,
pageStream,
mux,
(err) => logStreamDisconnectWarning('MetaMask Inpage', err)
)
pump(
mux,
pluginStream,
mux,
(err) => logStreamDisconnectWarning('MetaMask Background', err)
)
// connect ping stream
const pongStream = new PongStream({ objectMode: true })
pongStream.pipe(mx.createStream('pingpong')).pipe(pongStream)
pump(
mux,
pongStream,
mux,
(err) => logStreamDisconnectWarning('MetaMask PingPongStream', err)
)
// connect phishing warning stream
const phishingStream = mx.createStream('phishing')
const phishingStream = mux.createStream('phishing')
phishingStream.once('data', redirectToPhishingWarning)
// ignore unused channels (handled by background, inpage)
mx.ignoreStream('provider')
mx.ignoreStream('publicConfig')
mux.ignoreStream('provider')
mux.ignoreStream('publicConfig')
}
function logStreamDisconnectWarning (remoteLabel, err) {
let warningMsg = `MetamaskContentscript - lost connection to ${remoteLabel}`
if (err) warningMsg += '\n' + err.stack
console.warn(warningMsg)
}
function shouldInjectWeb3 () {

@ -0,0 +1,61 @@
const ObservableStore = require('obs-store')
const PendingBalanceCalculator = require('../lib/pending-balance-calculator')
const BN = require('ethereumjs-util').BN
class BalanceController {
constructor (opts = {}) {
const { address, accountTracker, txController, blockTracker } = opts
this.address = address
this.accountTracker = accountTracker
this.txController = txController
this.blockTracker = blockTracker
const initState = {
ethBalance: undefined,
}
this.store = new ObservableStore(initState)
this.balanceCalc = new PendingBalanceCalculator({
getBalance: () => this._getBalance(),
getPendingTransactions: this._getPendingTransactions.bind(this),
})
this._registerUpdates()
}
async updateBalance () {
const balance = await this.balanceCalc.getBalance()
this.store.updateState({
ethBalance: balance,
})
}
_registerUpdates () {
const update = this.updateBalance.bind(this)
this.txController.on('submitted', update)
this.txController.on('confirmed', update)
this.txController.on('failed', update)
this.accountTracker.store.subscribe(update)
this.blockTracker.on('block', update)
}
async _getBalance () {
const { accounts } = this.accountTracker.store.getState()
const entry = accounts[this.address]
const balance = entry.balance
return balance ? new BN(balance.substring(2), 16) : undefined
}
async _getPendingTransactions () {
const pending = this.txController.getFilteredTxList({
from: this.address,
status: 'submitted',
err: undefined,
})
return pending
}
}
module.exports = BalanceController

@ -0,0 +1,66 @@
const ObservableStore = require('obs-store')
const extend = require('xtend')
const BalanceController = require('./balance')
class ComputedbalancesController {
constructor (opts = {}) {
const { accountTracker, txController, blockTracker } = opts
this.accountTracker = accountTracker
this.txController = txController
this.blockTracker = blockTracker
const initState = extend({
computedBalances: {},
}, opts.initState)
this.store = new ObservableStore(initState)
this.balances = {}
this._initBalanceUpdating()
}
updateAllBalances () {
for (let address in this.accountTracker.store.getState().accounts) {
this.balances[address].updateBalance()
}
}
_initBalanceUpdating () {
const store = this.accountTracker.store.getState()
this.addAnyAccountsFromStore(store)
this.accountTracker.store.subscribe(this.addAnyAccountsFromStore.bind(this))
}
addAnyAccountsFromStore(store) {
const balances = store.accounts
for (let address in balances) {
this.trackAddressIfNotAlready(address)
}
}
trackAddressIfNotAlready (address) {
const state = this.store.getState()
if (!(address in state.computedBalances)) {
this.trackAddress(address)
}
}
trackAddress (address) {
let updater = new BalanceController({
address,
accountTracker: this.accountTracker,
txController: this.txController,
blockTracker: this.blockTracker,
})
updater.store.subscribe((accountBalance) => {
let newState = this.store.getState()
newState.computedBalances[address] = accountBalance
this.store.updateState(newState)
})
this.balances[address] = updater
updater.updateBalance()
}
}
module.exports = ComputedbalancesController

@ -32,7 +32,7 @@ module.exports = class TransactionController extends EventEmitter {
this.provider = opts.provider
this.blockTracker = opts.blockTracker
this.signEthTx = opts.signTransaction
this.ethStore = opts.ethStore
this.accountTracker = opts.accountTracker
this.memStore = new ObservableStore({})
this.query = new EthQuery(this.provider)
@ -61,7 +61,7 @@ module.exports = class TransactionController extends EventEmitter {
nonceTracker: this.nonceTracker,
retryLimit: 3500, // Retry 3500 blocks, or about 1 day.
getBalance: (address) => {
const account = this.ethStore.getState().accounts[address]
const account = this.accountTracker.getState().accounts[address]
if (!account) return
return account.balance
},
@ -78,7 +78,7 @@ module.exports = class TransactionController extends EventEmitter {
this.blockTracker.on('rawBlock', this.pendingTxTracker.checkForTxInBlock.bind(this.pendingTxTracker))
// this is a little messy but until ethstore has been either
// removed or redone this is to guard against the race condition
// where ethStore hasent been populated by the results yet
// where accountTracker hasent been populated by the results yet
this.blockTracker.once('latest', () => {
this.blockTracker.on('latest', this.pendingTxTracker.resubmitPendingTxs.bind(this.pendingTxTracker))
})

@ -35,8 +35,9 @@ class KeyringController extends EventEmitter {
keyrings: [],
identities: {},
})
this.ethStore = opts.ethStore
this.encryptor = encryptor
this.accountTracker = opts.accountTracker
this.encryptor = opts.encryptor || encryptor
this.keyrings = []
this.getNetwork = opts.getNetwork
}
@ -171,9 +172,9 @@ class KeyringController extends EventEmitter {
return this.setupAccounts(checkedAccounts)
})
.then(() => this.persistAllKeyrings())
.then(() => this._updateMemStoreKeyrings())
.then(() => this.fullUpdate())
.then(() => {
this._updateMemStoreKeyrings()
return keyring
})
}
@ -208,6 +209,7 @@ class KeyringController extends EventEmitter {
return selectedKeyring.addAccounts(1)
.then(this.setupAccounts.bind(this))
.then(this.persistAllKeyrings.bind(this))
.then(this._updateMemStoreKeyrings.bind(this))
.then(this.fullUpdate.bind(this))
}
@ -337,7 +339,7 @@ class KeyringController extends EventEmitter {
//
// Initializes the provided account array
// Gives them numerically incremented nicknames,
// and adds them to the ethStore for regular balance checking.
// and adds them to the accountTracker for regular balance checking.
setupAccounts (accounts) {
return this.getAccounts()
.then((loadedAccounts) => {
@ -360,7 +362,7 @@ class KeyringController extends EventEmitter {
throw new Error('Problem loading account.')
}
const address = normalizeAddress(account)
this.ethStore.addAccount(address)
this.accountTracker.addAccount(address)
return this.createNickname(address)
}
@ -566,12 +568,12 @@ class KeyringController extends EventEmitter {
clearKeyrings () {
let accounts
try {
accounts = Object.keys(this.ethStore.getState())
accounts = Object.keys(this.accountTracker.getState())
} catch (e) {
accounts = []
}
accounts.forEach((address) => {
this.ethStore.removeAccount(address)
this.accountTracker.removeAccount(address)
})
// clear keyrings from memory

@ -1,4 +1,4 @@
/* Ethereum Store
/* Account Tracker
*
* This module is responsible for tracking any number of accounts
* and caching their current balances & transaction counts.
@ -10,19 +10,21 @@
const async = require('async')
const EthQuery = require('eth-query')
const ObservableStore = require('obs-store')
const EventEmitter = require('events').EventEmitter
function noop () {}
class EthereumStore extends ObservableStore {
class AccountTracker extends EventEmitter {
constructor (opts = {}) {
super({
super()
const initState = {
accounts: {},
transactions: {},
currentBlockNumber: '0',
currentBlockHash: '',
currentBlockGasLimit: '',
})
}
this.store = new ObservableStore(initState)
this._provider = opts.provider
this._query = new EthQuery(this._provider)
this._blockTracker = opts.blockTracker
@ -37,34 +39,19 @@ class EthereumStore extends ObservableStore {
//
addAccount (address) {
const accounts = this.getState().accounts
const accounts = this.store.getState().accounts
accounts[address] = {}
this.updateState({ accounts })
this.store.updateState({ accounts })
if (!this._currentBlockNumber) return
this._updateAccount(address)
}
removeAccount (address) {
const accounts = this.getState().accounts
const accounts = this.store.getState().accounts
delete accounts[address]
this.updateState({ accounts })
this.store.updateState({ accounts })
}
addTransaction (txHash) {
const transactions = this.getState().transactions
transactions[txHash] = {}
this.updateState({ transactions })
if (!this._currentBlockNumber) return
this._updateTransaction(this._currentBlockNumber, txHash, noop)
}
removeTransaction (txHash) {
const transactions = this.getState().transactions
delete transactions[txHash]
this.updateState({ transactions })
}
//
// private
//
@ -72,53 +59,32 @@ class EthereumStore extends ObservableStore {
_updateForBlock (block) {
const blockNumber = '0x' + block.number.toString('hex')
this._currentBlockNumber = blockNumber
this.updateState({ currentBlockNumber: parseInt(blockNumber) })
this.updateState({ currentBlockHash: `0x${block.hash.toString('hex')}`})
this.updateState({ currentBlockGasLimit: `0x${block.gasLimit.toString('hex')}` })
this.store.updateState({ currentBlockGasLimit: `0x${block.gasLimit.toString('hex')}` })
async.parallel([
this._updateAccounts.bind(this),
this._updateTransactions.bind(this, blockNumber),
], (err) => {
if (err) return console.error(err)
this.emit('block', this.getState())
this.emit('block', this.store.getState())
})
}
_updateAccounts (cb = noop) {
const accounts = this.getState().accounts
const accounts = this.store.getState().accounts
const addresses = Object.keys(accounts)
async.each(addresses, this._updateAccount.bind(this), cb)
}
_updateAccount (address, cb = noop) {
const accounts = this.getState().accounts
this._getAccount(address, (err, result) => {
if (err) return cb(err)
result.address = address
const accounts = this.store.getState().accounts
// only populate if the entry is still present
if (accounts[address]) {
accounts[address] = result
this.updateState({ accounts })
}
cb(null, result)
})
}
_updateTransactions (block, cb = noop) {
const transactions = this.getState().transactions
const txHashes = Object.keys(transactions)
async.each(txHashes, this._updateTransaction.bind(this, block), cb)
}
_updateTransaction (block, txHash, cb = noop) {
// would use the block here to determine how many confirmations the tx has
const transactions = this.getState().transactions
this._query.getTransaction(txHash, (err, result) => {
if (err) return cb(err)
// only populate if the entry is still present
if (transactions[txHash]) {
transactions[txHash] = result
this.updateState({ transactions })
this.store.updateState({ accounts })
}
cb(null, result)
})
@ -135,4 +101,4 @@ class EthereumStore extends ObservableStore {
}
module.exports = EthereumStore
module.exports = AccountTracker

@ -0,0 +1,15 @@
// log rpc activity
module.exports = createLoggerMiddleware
function createLoggerMiddleware({ origin }) {
return function loggerMiddleware (req, res, next, end) {
next((cb) => {
if (res.error) {
log.error('Error in RPC response:\n', res)
}
if (req.isMetamaskInternal) return
log.info(`RPC (${origin}):`, req, '->', res)
cb()
})
}
}

@ -0,0 +1,9 @@
// append dapp origin domain to request
module.exports = createOriginMiddleware
function createOriginMiddleware({ origin }) {
return function originMiddleware (req, res, next, end) {
req.origin = origin
next()
}
}

@ -0,0 +1,13 @@
module.exports = createProviderMiddleware
// forward requests to provider
function createProviderMiddleware({ provider }) {
return (req, res, next, end) => {
provider.sendAsync(req, (err, _res) => {
if (err) return end(err)
res.result = _res.result
end()
})
}
}

@ -1,8 +1,9 @@
const pipe = require('pump')
const StreamProvider = require('web3-stream-provider')
const pump = require('pump')
const RpcEngine = require('json-rpc-engine')
const createIdRemapMiddleware = require('json-rpc-engine/src/idRemapMiddleware')
const createStreamMiddleware = require('json-rpc-middleware-stream')
const LocalStorageStore = require('obs-store')
const ObjectMultiplex = require('./obj-multiplex')
const createRandomId = require('./random-id')
const ObjectMultiplex = require('obj-multiplex')
module.exports = MetamaskInpageProvider
@ -10,61 +11,49 @@ function MetamaskInpageProvider (connectionStream) {
const self = this
// setup connectionStream multiplexing
var multiStream = self.multiStream = ObjectMultiplex()
pipe(
const mux = self.mux = new ObjectMultiplex()
pump(
connectionStream,
multiStream,
mux,
connectionStream,
(err) => logStreamDisconnectWarning('MetaMask', err)
)
// subscribe to metamask public config (one-way)
self.publicConfigStore = new LocalStorageStore({ storageKey: 'MetaMask-Config' })
pipe(
multiStream.createStream('publicConfig'),
pump(
mux.createStream('publicConfig'),
self.publicConfigStore,
(err) => logStreamDisconnectWarning('MetaMask PublicConfigStore', err)
)
// ignore phishing warning message (handled elsewhere)
multiStream.ignoreStream('phishing')
mux.ignoreStream('phishing')
// connect to async provider
const asyncProvider = self.asyncProvider = new StreamProvider()
pipe(
asyncProvider,
multiStream.createStream('provider'),
asyncProvider,
const streamMiddleware = createStreamMiddleware()
pump(
streamMiddleware.stream,
mux.createStream('provider'),
streamMiddleware.stream,
(err) => logStreamDisconnectWarning('MetaMask RpcProvider', err)
)
// start and stop polling to unblock first block lock
self.idMap = {}
// handle sendAsync requests via asyncProvider
self.sendAsync = function (payload, cb) {
// rewrite request ids
var request = eachJsonMessage(payload, (_message) => {
const message = Object.assign({}, _message)
const newId = createRandomId()
self.idMap[newId] = message.id
message.id = newId
return message
})
// forward to asyncProvider
asyncProvider.sendAsync(request, function (err, res) {
if (err) return cb(err)
// transform messages to original ids
eachJsonMessage(res, (message) => {
var oldId = self.idMap[message.id]
delete self.idMap[message.id]
message.id = oldId
return message
})
cb(null, res)
})
}
// handle sendAsync requests via dapp-side rpc engine
const rpcEngine = new RpcEngine()
rpcEngine.push(createIdRemapMiddleware())
rpcEngine.push(streamMiddleware)
self.rpcEngine = rpcEngine
}
// handle sendAsync requests via asyncProvider
// also remap ids inbound and outbound
MetamaskInpageProvider.prototype.sendAsync = function (payload, cb) {
const self = this
self.rpcEngine.handle(payload, cb)
}
MetamaskInpageProvider.prototype.send = function (payload) {
const self = this
@ -110,10 +99,6 @@ MetamaskInpageProvider.prototype.send = function (payload) {
}
}
MetamaskInpageProvider.prototype.sendAsync = function () {
throw new Error('MetamaskInpageProvider - sendAsync not overwritten')
}
MetamaskInpageProvider.prototype.isConnected = function () {
return true
}
@ -122,14 +107,6 @@ MetamaskInpageProvider.prototype.isMetaMask = true
// util
function eachJsonMessage (payload, transformFn) {
if (Array.isArray(payload)) {
return payload.map(transformFn)
} else {
return transformFn(payload)
}
}
function logStreamDisconnectWarning (remoteLabel, err) {
let warningMsg = `MetamaskInpageProvider - lost connection to ${remoteLabel}`
if (err) warningMsg += '\n' + err.stack

@ -1,48 +0,0 @@
const through = require('through2')
module.exports = ObjectMultiplex
function ObjectMultiplex (opts) {
opts = opts || {}
// create multiplexer
const mx = through.obj(function (chunk, enc, cb) {
const name = chunk.name
const data = chunk.data
if (!name) {
console.warn(`ObjectMultiplex - Malformed chunk without name "${chunk}"`)
return cb()
}
const substream = mx.streams[name]
if (!substream) {
console.warn(`ObjectMultiplex - orphaned data for stream "${name}"`)
} else {
if (substream.push) substream.push(data)
}
return cb()
})
mx.streams = {}
// create substreams
mx.createStream = function (name) {
const substream = mx.streams[name] = through.obj(function (chunk, enc, cb) {
mx.push({
name: name,
data: chunk,
})
return cb()
})
mx.on('end', function () {
return substream.emit('end')
})
if (opts.error) {
mx.on('error', function () {
return substream.emit('error')
})
}
return substream
}
// ignore streams (dont display orphaned data warning)
mx.ignoreStream = function (name) {
mx.streams[name] = true
}
return mx
}

@ -0,0 +1,51 @@
const BN = require('ethereumjs-util').BN
const normalize = require('eth-sig-util').normalize
class PendingBalanceCalculator {
// Must be initialized with two functions:
// getBalance => Returns a promise of a BN of the current balance in Wei
// getPendingTransactions => Returns an array of TxMeta Objects,
// which have txParams properties, which include value, gasPrice, and gas,
// all in a base=16 hex format.
constructor ({ getBalance, getPendingTransactions }) {
this.getPendingTransactions = getPendingTransactions
this.getNetworkBalance = getBalance
}
async getBalance() {
const results = await Promise.all([
this.getNetworkBalance(),
this.getPendingTransactions(),
])
const [ balance, pending ] = results
if (!balance) return undefined
const pendingValue = pending.reduce((total, tx) => {
return total.add(this.calculateMaxCost(tx))
}, new BN(0))
return `0x${balance.sub(pendingValue).toString(16)}`
}
calculateMaxCost (tx) {
const txValue = tx.txParams.value
const value = this.hexToBn(txValue)
const gasPrice = this.hexToBn(tx.txParams.gasPrice)
const gas = tx.txParams.gas
const gasLimit = tx.txParams.gasLimit
const gasLimitBn = this.hexToBn(gas || gasLimit)
const gasCost = gasPrice.mul(gasLimitBn)
return value.add(gasCost)
}
hexToBn (hex) {
return new BN(normalize(hex).substring(2), 16)
}
}
module.exports = PendingBalanceCalculator

@ -74,6 +74,9 @@ module.exports = class PendingTransactionTracker extends EventEmitter {
Dont marked as failed if the error is a "known" transaction warning
"there is already a transaction with the same sender-nonce
but higher/same gas price"
Also don't mark as failed if it has ever been broadcast successfully.
A successful broadcast means it may still be mined.
*/
const errorMessage = err.message.toLowerCase()
const isKnownTx = (
@ -86,6 +89,7 @@ module.exports = class PendingTransactionTracker extends EventEmitter {
// other
|| errorMessage.includes('gateway timeout')
|| errorMessage.includes('nonce too low')
|| txMeta.retryCount > 1
)
// ignore resubmit warnings, return early
if (isKnownTx) return
@ -116,10 +120,12 @@ module.exports = class PendingTransactionTracker extends EventEmitter {
// Only auto-submit already-signed txs:
if (!('rawTx' in txMeta)) return
// Increment a try counter.
txMeta.retryCount++
const rawTx = txMeta.rawTx
return await this.publishTransaction(rawTx)
const txHash = await this.publishTransaction(rawTx)
// Increment successful tries:
txMeta.retryCount++
return txHash
}
async _checkPendingTx (txMeta) {

@ -1,5 +1,6 @@
const Duplex = require('readable-stream').Duplex
const inherits = require('util').inherits
const noop = function(){}
module.exports = PortDuplexStream
@ -20,20 +21,14 @@ PortDuplexStream.prototype._onMessage = function (msg) {
if (Buffer.isBuffer(msg)) {
delete msg._isBuffer
var data = new Buffer(msg)
// console.log('PortDuplexStream - saw message as buffer', data)
this.push(data)
} else {
// console.log('PortDuplexStream - saw message', msg)
this.push(msg)
}
}
PortDuplexStream.prototype._onDisconnect = function () {
try {
this.push(null)
} catch (err) {
this.emit('error', err)
}
this.destroy()
}
// stream plumbing
@ -45,19 +40,12 @@ PortDuplexStream.prototype._write = function (msg, encoding, cb) {
if (Buffer.isBuffer(msg)) {
var data = msg.toJSON()
data._isBuffer = true
// console.log('PortDuplexStream - sent message as buffer', data)
this._port.postMessage(data)
} else {
// console.log('PortDuplexStream - sent message', msg)
this._port.postMessage(msg)
}
} catch (err) {
// console.error(err)
return cb(new Error('PortDuplexStream - disconnected'))
}
cb()
}
// util
function noop () {}

@ -1,6 +1,6 @@
const Through = require('through2')
const endOfStream = require('end-of-stream')
const ObjectMultiplex = require('./obj-multiplex')
const ObjectMultiplex = require('obj-multiplex')
const pump = require('pump')
module.exports = {
jsonParseStream: jsonParseStream,
@ -23,14 +23,14 @@ function jsonStringifyStream () {
}
function setupMultiplex (connectionStream) {
var mx = ObjectMultiplex()
connectionStream.pipe(mx).pipe(connectionStream)
endOfStream(mx, function (err) {
const mux = new ObjectMultiplex()
pump(
connectionStream,
mux,
connectionStream,
(err) => {
if (err) console.error(err)
})
endOfStream(connectionStream, function (err) {
if (err) console.error(err)
mx.destroy()
})
return mx
}
)
return mux
}

@ -229,6 +229,7 @@ module.exports = class TransactionStateManger extends EventEmitter {
const txMeta = this.getTx(txId)
txMeta.status = status
this.emit(`${txMeta.id}:${status}`, txId)
this.emit(`${status}`, txId)
if (status === 'submitted' || status === 'rejected') {
this.emit(`${txMeta.id}:finished`, txMeta)
}

@ -1,12 +1,18 @@
const EventEmitter = require('events')
const extend = require('xtend')
const promiseToCallback = require('promise-to-callback')
const pipe = require('pump')
const pump = require('pump')
const Dnode = require('dnode')
const ObservableStore = require('obs-store')
const EthStore = require('./lib/eth-store')
const AccountTracker = require('./lib/account-tracker')
const EthQuery = require('eth-query')
const streamIntoProvider = require('web3-stream-provider/handler')
const RpcEngine = require('json-rpc-engine')
const debounce = require('debounce')
const createEngineStream = require('json-rpc-middleware-stream/engineStream')
const createFilterMiddleware = require('eth-json-rpc-filters')
const createOriginMiddleware = require('./lib/createOriginMiddleware')
const createLoggerMiddleware = require('./lib/createLoggerMiddleware')
const createProviderMiddleware = require('./lib/createProviderMiddleware')
const setupMultiplex = require('./lib/stream-utils.js').setupMultiplex
const KeyringController = require('./keyring-controller')
const NetworkController = require('./controllers/network')
@ -20,12 +26,11 @@ const BlacklistController = require('./controllers/blacklist')
const MessageManager = require('./lib/message-manager')
const PersonalMessageManager = require('./lib/personal-message-manager')
const TransactionController = require('./controllers/transactions')
const BalancesController = require('./controllers/computed-balances')
const ConfigManager = require('./lib/config-manager')
const nodeify = require('./lib/nodeify')
const accountImporter = require('./account-import-strategies')
const getBuyEthUrl = require('./lib/buy-eth-url')
const debounce = require('debounce')
const version = require('../manifest.json').version
module.exports = class MetamaskController extends EventEmitter {
@ -77,22 +82,30 @@ module.exports = class MetamaskController extends EventEmitter {
// rpc provider
this.provider = this.initializeProvider()
this.blockTracker = this.provider
// eth data query tools
this.ethQuery = new EthQuery(this.provider)
this.ethStore = new EthStore({
// account tracker watches balances, nonces, and any code at their address.
this.accountTracker = new AccountTracker({
provider: this.provider,
blockTracker: this.provider,
blockTracker: this.blockTracker,
})
// key mgmt
this.keyringController = new KeyringController({
initState: initState.KeyringController,
ethStore: this.ethStore,
accountTracker: this.accountTracker,
getNetwork: this.networkController.getNetworkState.bind(this.networkController),
encryptor: opts.encryptor || undefined,
})
this.keyringController.on('newAccount', (address) => {
this.preferencesController.setSelectedAddress(address)
this.accountTracker.addAccount(address)
})
this.keyringController.on('removedAccount', (address) => {
this.accountTracker.removeAccount(address)
})
// address book controller
@ -109,12 +122,23 @@ module.exports = class MetamaskController extends EventEmitter {
getNetwork: this.networkController.getNetworkState.bind(this),
signTransaction: this.keyringController.signTransaction.bind(this.keyringController),
provider: this.provider,
blockTracker: this.provider,
blockTracker: this.blockTracker,
ethQuery: this.ethQuery,
ethStore: this.ethStore,
accountTracker: this.accountTracker,
})
this.txController.on('newUnaprovedTx', opts.showUnapprovedTx.bind(opts))
// computed balances (accounting for pending transactions)
this.balancesController = new BalancesController({
accountTracker: this.accountTracker,
txController: this.txController,
blockTracker: this.blockTracker,
})
this.networkController.on('networkDidChange', () => {
this.balancesController.updateAllBalances()
})
this.balancesController.updateAllBalances()
// notices
this.noticeController = new NoticeController({
initState: initState.NoticeController,
@ -166,8 +190,9 @@ module.exports = class MetamaskController extends EventEmitter {
// manual mem state subscriptions
this.networkController.store.subscribe(this.sendUpdate.bind(this))
this.ethStore.subscribe(this.sendUpdate.bind(this))
this.accountTracker.store.subscribe(this.sendUpdate.bind(this))
this.txController.memStore.subscribe(this.sendUpdate.bind(this))
this.balancesController.store.subscribe(this.sendUpdate.bind(this))
this.messageManager.memStore.subscribe(this.sendUpdate.bind(this))
this.personalMessageManager.memStore.subscribe(this.sendUpdate.bind(this))
this.keyringController.memStore.subscribe(this.sendUpdate.bind(this))
@ -242,16 +267,18 @@ module.exports = class MetamaskController extends EventEmitter {
const wallet = this.configManager.getWallet()
const vault = this.keyringController.store.getState().vault
const isInitialized = (!!wallet || !!vault)
return extend(
{
isInitialized,
},
this.networkController.store.getState(),
this.ethStore.getState(),
this.accountTracker.store.getState(),
this.txController.memStore.getState(),
this.messageManager.memStore.getState(),
this.personalMessageManager.memStore.getState(),
this.keyringController.memStore.getState(),
this.balancesController.store.getState(),
this.preferencesController.store.getState(),
this.addressBookController.store.getState(),
this.currencyController.store.getState(),
@ -337,36 +364,43 @@ module.exports = class MetamaskController extends EventEmitter {
setupUntrustedCommunication (connectionStream, originDomain) {
// Check if new connection is blacklisted
if (this.blacklistController.checkForPhishing(originDomain)) {
console.log('MetaMask - sending phishing warning for', originDomain)
log.debug('MetaMask - sending phishing warning for', originDomain)
this.sendPhishingWarning(connectionStream, originDomain)
return
}
// setup multiplexing
const mx = setupMultiplex(connectionStream)
const mux = setupMultiplex(connectionStream)
// connect features
this.setupProviderConnection(mx.createStream('provider'), originDomain)
this.setupPublicConfig(mx.createStream('publicConfig'))
this.setupProviderConnection(mux.createStream('provider'), originDomain)
this.setupPublicConfig(mux.createStream('publicConfig'))
}
setupTrustedCommunication (connectionStream, originDomain) {
// setup multiplexing
const mx = setupMultiplex(connectionStream)
const mux = setupMultiplex(connectionStream)
// connect features
this.setupControllerConnection(mx.createStream('controller'))
this.setupProviderConnection(mx.createStream('provider'), originDomain)
this.setupControllerConnection(mux.createStream('controller'))
this.setupProviderConnection(mux.createStream('provider'), originDomain)
}
sendPhishingWarning (connectionStream, hostname) {
const mx = setupMultiplex(connectionStream)
const phishingStream = mx.createStream('phishing')
const mux = setupMultiplex(connectionStream)
const phishingStream = mux.createStream('phishing')
phishingStream.write({ hostname })
}
setupControllerConnection (outStream) {
const api = this.getApi()
const dnode = Dnode(api)
outStream.pipe(dnode).pipe(outStream)
pump(
outStream,
dnode,
outStream,
(err) => {
if (err) log.error(err)
}
)
dnode.on('remote', (remote) => {
// push updates to popup
const sendUpdate = remote.sendUpdate.bind(remote)
@ -374,27 +408,42 @@ module.exports = class MetamaskController extends EventEmitter {
})
}
setupProviderConnection (outStream, originDomain) {
streamIntoProvider(outStream, this.provider, onRequest, onResponse)
// append dapp origin domain to request
function onRequest (request) {
request.origin = originDomain
}
// log rpc activity
function onResponse (err, request, response) {
if (err) return console.error(err)
if (response.error) {
console.error('Error in RPC response:\n', response)
}
if (request.isMetamaskInternal) return
log.info(`RPC (${originDomain}):`, request, '->', response)
setupProviderConnection (outStream, origin) {
// setup json rpc engine stack
const engine = new RpcEngine()
// create filter polyfill middleware
const filterMiddleware = createFilterMiddleware({
provider: this.provider,
blockTracker: this.blockTracker,
})
engine.push(createOriginMiddleware({ origin }))
engine.push(createLoggerMiddleware({ origin }))
engine.push(filterMiddleware)
engine.push(createProviderMiddleware({ provider: this.provider }))
// setup connection
const providerStream = createEngineStream({ engine })
pump(
outStream,
providerStream,
outStream,
(err) => {
// cleanup filter polyfill middleware
filterMiddleware.destroy()
if (err) log.error(err)
}
)
}
setupPublicConfig (outStream) {
pipe(
pump(
this.publicConfigStore,
outStream
outStream,
(err) => {
if (err) log.error(err)
}
)
}

@ -1,10 +1,17 @@
machine:
node:
version: 8.1.4
dependencies:
pre:
- "npm i -g testem"
- "npm i -g mocha"
test:
override:
- "npm run ci"
- "npm test"
dependencies:
pre:
- sudo apt-get update
# get latest stable firefox
- sudo apt-get install firefox
- firefox_cmd=`which firefox`; sudo rm -f $firefox_cmd; sudo ln -s `which firefox.ubuntu` $firefox_cmd
# get latest stable chrome
- wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
- sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
- sudo apt-get update
- sudo apt-get install google-chrome-stable

@ -14,13 +14,13 @@
</body>
<style>
html, body, #app-content, .super-dev-container {
html, body, #test-container, .super-dev-container {
height: 100%;
width: 100%;
position: relative;
background: white;
}
.mock-app-root {
#app-content {
background: #F7F7F7;
}
</style>

@ -4,6 +4,7 @@
"isUnlocked": false,
"rpcTarget": "https://rawtestrpc.metamask.io/",
"identities": {},
"computedBalances": {},
"frequentRpcList": [],
"unapprovedTxs": {},
"currentCurrency": "USD",
@ -48,5 +49,6 @@
"isLoading": false,
"warning": null
},
"identities": {}
"identities": {},
"computedBalances": {}
}

@ -18,13 +18,14 @@
</body>
<style>
html, body, #app-content, .super-dev-container {
html, body, #test-container, .super-dev-container {
height: 100%;
width: 100%;
position: relative;
background: white;
}
.mock-app-root {
#app-content {
background: #F7F7F7;
}
</style>

@ -0,0 +1,92 @@
# Guide to Porting MetaMask to a New Environment
MetaMask has been under continuous development for nearly two years now, and we’ve gradually discovered some very useful abstractions, that have allowed us to grow more easily. A couple of those layers together allow MetaMask to be ported to new environments and contexts increasingly easily.
### The MetaMask Controller
The core functionality of MetaMask all lives in what we call [The MetaMask Controller](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js). Our goal for this file is for it to eventually be its own javascript module that can be imported into any JS-compatible context, allowing it to fully manage an app's relationship to Ethereum.
#### Constructor
When calling `new MetaMask(opts)`, many platform-specific options are configured. The keys on `opts` are as follows:
- initState: The last emitted state, used for restoring persistent state between sessions.
- platform: The `platform` object defines a variety of platform-specific functions, including opening the confirmation view, and opening web sites.
- encryptor - An object that provides access to the desired encryption methods.
##### Encryptor
An object that provides two simple methods, which can encrypt in any format you prefer. This parameter is optional, and will default to the browser-native WebCrypto API.
- encrypt(password, object) - returns a Promise of a string that is ready for storage.
- decrypt(password, encryptedString) - Accepts the encrypted output of `encrypt` and returns a Promise of a restored `object` as it was encrypted.
##### Platform Options
The `platform` object has a variety of options:
- reload (function) - Will be called when MetaMask would like to reload its own context.
- openWindow ({ url }) - Will be called when MetaMask would like to open a web page. It will be passed a single `options` object with a `url` key, with a string value.
- getVersion() - Should return the current MetaMask version, as described in the current `CHANGELOG.md` or `app/manifest.json`.
#### [metamask.getState()](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L241)
This method returns a javascript object representing the current MetaMask state. This includes things like known accounts, sent transactions, current exchange rates, and more! The controller is also an event emitter, so you can subscribe to state updates via `metamask.on('update', handleStateUpdate)`. State examples available [here](https://github.com/MetaMask/metamask-extension/tree/master/development/states) under the `metamask` key. (Warning: some are outdated)
#### [metamask.getApi()](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L274-L335)
Returns a JavaScript object filled with callback functions representing every operation our user interface ever performs. Everything from creating new accounts, changing the current network, to sending a transaction, is provided via these API methods. We export this external API on an object because it allows us to easily expose this API over a port using [dnode](https://www.npmjs.com/package/dnode), which is how our WebExtension's UI works!
### The UI
The MetaMask UI is essentially just a website that can be configured by passing it the API and state subscriptions from above. Anyone could make a UI that consumes these, effectively reskinning MetaMask.
You can see this in action in our file [ui/index.js](https://github.com/MetaMask/metamask-extension/blob/master/ui/index.js). There you can see an argument being passed in named `accountManager`, which is essentially a MetaMask controller (forgive its really outdated parameter name!). With access to that object, the UI is able to initialize a whole React/Redux app that relies on this API for its account/blockchain-related/persistent states.
## Putting it Together
As an example, a WebExtension is always defined by a `manifest.json` file. [In ours](https://github.com/MetaMask/metamask-extension/blob/master/app/manifest.json#L31), you can see that [background.js](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/background.js) is defined as a script to run in the background, and this is the file that we use to initialize the MetaMask controller.
In that file, there's a lot going on, so it's maybe worth focusing on our MetaMask controller constructor to start. It looks something like this:
```javascript
const controller = new MetamaskController({
// User confirmation callbacks:
showUnconfirmedMessage: triggerUi,
unlockAccountMessage: triggerUi,
showUnapprovedTx: triggerUi,
// initial state
initState,
// platform specific api
platform,
})
```
Since `background.js` is essentially the Extension setup file, we can see it doing all the things specific to the extension platform:
- Defining how to open the UI for new messages, transactions, and even requests to unlock (reveal to the site) their account.
- Provide the instance's initial state, leaving MetaMask persistence to the platform.
- Providing a `platform` object. This is becoming our catch-all adapter for platforms to define a few other platform-variant features we require, like opening a web link. (Soon we will be moving encryption out here too, since our browser-encryption isn't portable enough!)
## Ports, streams, and Web3!
Everything so far has been enough to create a MetaMask wallet on virtually any platform that runs JS, but MetaMask's most unique feature isn't being a wallet, it's providing an Ethereum-enabled JavaScript context to websites.
MetaMask has two kinds of [duplex stream APIs](https://github.com/substack/stream-handbook#duplex) that it exposes:
- [metamask.setupTrustedCommunication(connectionStream, originDomain)](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L352) - This stream is used to connect the user interface over a remote port, and may not be necessary for contexts where the interface and the metamask-controller share a process.
- [metamask.setupUntrustedCommunication(connectionStream, originDomain)](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/metamask-controller.js#L337) - This method is used to connect a new web site's web3 API to MetaMask's blockchain connection. Additionally, the `originDomain` is used to block detected phishing sites.
### Web3 as a Stream
If you are making a MetaMask-powered browser for a new platform, one of the trickiest tasks will be injecting the Web3 API into websites that are visited. On WebExtensions, we actually have to pipe data through a total of three JS contexts just to let sites talk to our background process (site -> contentscript -> background).
To make this as easy as possible, we use one of our favorite internal tools, [web3-provider-engine](https://www.npmjs.com/package/web3-provider-engine) to construct a custom web3 provider object whose source of truth is a stream that we connect to remotely.
To see how we do that, you can refer to the [inpage script](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/inpage.js) that we inject into every website. There you can see it creates a multiplex stream to the background, and uses it to initialize what we call the [inpage-provider](https://github.com/MetaMask/metamask-extension/blob/master/app/scripts/lib/inpage-provider.js), which you can see stubs a few methods out, but mostly just passes calls to `sendAsync` through the stream it's passed! That's really all the magic that's needed to create a web3-like API in a remote context, once you have a stream to MetaMask available.
In `inpage.js` you can see we create a `PortStream`, that's just a class we use to wrap WebExtension ports as streams, so we can reuse our favorite stream abstraction over the more irregular API surface of the WebExtension. In a new platform, you will probably need to construct this stream differently. The key is that you need to construct a stream that talks from the site context to the background. Once you have that set up, it works like magic!
If streams seem new and confusing to you, that's ok, they can seem strange at first. To help learn them, we highly recommend reading Substack's [Stream Handbook](https://github.com/substack/stream-handbook), or going through NodeSchool's interactive command-line class [Stream Adventure](https://github.com/workshopper/stream-adventure), also maintained by Substack.
## Conclusion
I hope this has been helpful to you! If you have any other questionsm, or points you think need clarification in this guide, please [open an issue on our GitHub](https://github.com/MetaMask/metamask-plugin/issues/new)!

@ -1,7 +1,6 @@
const createParentStream = require('iframe-stream').ParentStream
const SWcontroller = require('client-sw-ready-event/lib/sw-client.js')
const SwStream = require('sw-stream/lib/sw-stream.js')
const SetupUntrustedComunication = ('./lib/setup-untrusted-connection.js')
let intervalDelay = Math.floor(Math.random() * (30000 - 1000)) + 1000
const background = new SWcontroller({
@ -12,7 +11,7 @@ const background = new SWcontroller({
})
const pageStream = createParentStream()
background.on('ready', (_) => {
background.on('ready', () => {
let swStream = SwStream({
serviceWorker: background.controller,
context: 'dapp',

@ -2,8 +2,6 @@ const injectCss = require('inject-css')
const SWcontroller = require('client-sw-ready-event/lib/sw-client.js')
const SwStream = require('sw-stream/lib/sw-stream.js')
const MetaMaskUiCss = require('../../ui/css')
const setupIframe = require('./lib/setup-iframe.js')
const MetamaskInpageProvider = require('../../app/scripts/lib/inpage-provider.js')
const MetamascaraPlatform = require('../../app/scripts/platforms/window')
const startPopup = require('../../app/scripts/popup-core')
@ -17,6 +15,7 @@ const container = document.getElementById('app-content')
var name = 'popup'
window.METAMASK_UI_TYPE = name
window.METAMASK_PLATFORM_TYPE = 'mascara'
let intervalDelay = Math.floor(Math.random() * (30000 - 1000)) + 1000
@ -32,25 +31,39 @@ const connectApp = function (readSw) {
serviceWorker: background.controller,
context: name,
})
startPopup({container, connectionStream}, (err, store) => {
if (err) return displayCriticalError(err)
return new Promise((resolve, reject) => {
startPopup({ container, connectionStream }, (err, store) => {
console.log('hello from MetaMascara ui!')
if (err) reject(err)
store.subscribe(() => {
const state = store.getState()
if (state.appState.shouldClose) window.close()
})
resolve()
})
})
}
background.on('ready', (sw) => {
background.on('ready', async (sw) => {
try {
background.removeListener('updatefound', connectApp)
connectApp(sw)
await timeout(1000)
await connectApp(sw)
console.log('hello from cb ready event!')
} catch (e) {
console.error(e)
}
})
background.on('updatefound', () => window.location.reload())
background.on('updatefound', windowReload)
background.startWorker()
.then(() => {
setTimeout(() => {
const appContent = document.getElementById(`app-content`)
if (!appContent.children.length) window.location.reload()
}, 2000)
})
console.log('hello from MetaMascara ui!')
function windowReload() {
if (window.METAMASK_SKIP_RELOAD) return
window.location.reload()
}
function timeout (time) {
return new Promise((resolve) => {
setTimeout(resolve, time || 1500)
})
}

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>QUnit Example</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.0.0.css">
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<script src="https://code.jquery.com/qunit/qunit-2.0.0.js"></script>
<script src="./jquery-3.1.0.min.js"></script>
<script src="./helpers.js"></script>
<script src="./test-bundle.js"></script>
<script src="/testem.js"></script>
<div id="app-content"></div>
<script src="./bundle.js"></script>
</body>
</html>

@ -1,119 +0,0 @@
const PASSWORD = 'password123'
QUnit.module('first time usage')
QUnit.test('render init screen', function (assert) {
var done = assert.async()
let app
wait(1000).then(function() {
app = $('#app-content').contents()
const recurseNotices = function () {
let button = app.find('button')
if (button.html() === 'Accept') {
let termsPage = app.find('.markdown')[0]
termsPage.scrollTop = termsPage.scrollHeight
return wait().then(() => {
button.click()
return wait()
}).then(() => {
return recurseNotices()
})
} else {
return wait()
}
}
return recurseNotices()
}).then(function() {
// Scroll through terms
var title = app.find('h1').text()
assert.equal(title, 'MetaMask', 'title screen')
// enter password
var pwBox = app.find('#password-box')[0]
var confBox = app.find('#password-box-confirm')[0]
pwBox.value = PASSWORD
confBox.value = PASSWORD
return wait()
}).then(function() {
// create vault
var createButton = app.find('button.primary')[0]
createButton.click()
return wait(1500)
}).then(function() {
var created = app.find('h3')[0]
assert.equal(created.textContent, 'Vault Created', 'Vault created screen')
// Agree button
var button = app.find('button')[0]
assert.ok(button, 'button present')
button.click()
return wait(1000)
}).then(function() {
var detail = app.find('.account-detail-section')[0]
assert.ok(detail, 'Account detail section loaded.')
var sandwich = app.find('.sandwich-expando')[0]
sandwich.click()
return wait()
}).then(function() {
var sandwich = app.find('.menu-droppo')[0]
var children = sandwich.children
var lock = children[children.length - 2]
assert.ok(lock, 'Lock menu item found')
lock.click()
return wait(1000)
}).then(function() {
var pwBox = app.find('#password-box')[0]
pwBox.value = PASSWORD
var createButton = app.find('button.primary')[0]
createButton.click()
return wait(1000)
}).then(function() {
var detail = app.find('.account-detail-section')[0]
assert.ok(detail, 'Account detail section loaded again.')
return wait()
}).then(function (){
var qrButton = app.find('.fa.fa-qrcode')[0]
qrButton.click()
return wait(1000)
}).then(function (){
var qrHeader = app.find('.qr-header')[0]
var qrContainer = app.find('#qr-container')[0]
assert.equal(qrHeader.textContent, 'Account 1', 'Should show account label.')
assert.ok(qrContainer, 'QR Container found')
return wait()
}).then(function (){
var networkMenu = app.find('.network-indicator')[0]
networkMenu.click()
return wait()
}).then(function (){
var networkMenu = app.find('.network-indicator')[0]
var children = networkMenu.children
children.length[3]
assert.ok(children, 'All network options present')
done()
})
})

@ -0,0 +1,12 @@
const Helper = require('./util/mascara-test-helper.js')
window.addEventListener('load', () => {
window.METAMASK_SKIP_RELOAD = true
// inject app container
const body = document.body
const container = document.createElement('div')
container.id = 'app-content'
body.appendChild(container)
// start ui
require('../src/ui.js')
})

@ -1,13 +0,0 @@
launch_in_dev:
- Chrome
- Firefox
- Opera
launch_in_ci:
- Chrome
- Firefox
- Opera
framework:
- qunit
before_tests: "npm run mascaraCi"
after_tests: "rm ./background.js ./test-bundle.js ./bundle.js"
test_page: "./index.html"

@ -1,5 +0,0 @@
const Helper = require('./util/mascara-test-helper.js')
window.addEventListener('load', () => {
require('../src/ui.js')
})

@ -85,13 +85,19 @@ actions.update = function(stateName) {
var css = MetaMaskUiCss()
injectCss(css)
const container = document.querySelector('#app-content')
// parse opts
var store = configureStore(firstState)
// start app
render(
startApp()
function startApp(){
const body = document.body
const container = document.createElement('div')
container.id = 'test-container'
body.appendChild(container)
render(
h('.super-dev-container', [
h('button', {
@ -106,7 +112,7 @@ render(
h(Selector, { actions, selectedKey: selectedView, states, store }),
h('.mock-app-root', {
h('#app-content', {
style: {
height: '500px',
width: '360px',
@ -120,5 +126,5 @@ render(
]),
]
), container)
), container)
}

@ -6,31 +6,33 @@
"scripts": {
"start": "npm run dev",
"dev": "gulp dev --debug",
"disc": "gulp disc --debug",
"clear": "rm -rf node_modules/eth-contract-metadata && rm -rf node_modules/eth-phishing-detect",
"dist": "npm run clear && npm install && gulp dist",
"test": "npm run lint && npm run test-unit && npm run test-integration",
"test-unit": "METAMASK_ENV=test mocha --require test/helper.js --recursive \"test/unit/**/*.js\"",
"single-test": "METAMASK_ENV=test mocha --require test/helper.js",
"test-integration": "npm run buildMock && npm run buildCiUnits && testem ci -P 2",
"test-coverage": "nyc npm run test-unit && nyc report --reporter=text-lcov | coveralls",
"ci": "npm run lint && npm run test-coverage && npm run test-integration",
"lint": "gulp lint",
"buildCiUnits": "node test/integration/index.js",
"watch": "mocha watch --recursive \"test/unit/**/*.js\"",
"genStates": "node development/genStates.js",
"ui": "npm run genStates && beefy ui-dev.js:bundle.js --live --open --index=./development/index.html --cwd ./",
"ui": "npm run test:flat:build:states && beefy ui-dev.js:bundle.js --live --open --index=./development/index.html --cwd ./",
"mock": "beefy mock-dev.js:bundle.js --live --open --index=./development/index.html --cwd ./",
"buildMock": "npm run genStates && browserify ./mock-dev.js -o ./development/bundle.js",
"testem": "npm run buildMock && testem",
"watch": "mocha watch --recursive \"test/unit/**/*.js\"",
"mascara": "node ./mascara/example/server",
"dist": "npm run dist:clear && npm install && gulp dist",
"dist:clear": "rm -rf node_modules/eth-contract-metadata && rm -rf node_modules/eth-phishing-detect",
"test": "npm run lint && npm run test:coverage && npm run test:integration",
"test:unit": "METAMASK_ENV=test mocha --require test/helper.js --recursive \"test/unit/**/*.js\"",
"test:single": "METAMASK_ENV=test mocha --require test/helper.js",
"test:integration": "npm run test:flat && npm run test:mascara",
"test:coverage": "nyc npm run test:unit && npm run test:coveralls-upload",
"test:coveralls-upload": "if [ $COVERALLS_REPO_TOKEN ]; then nyc report --reporter=text-lcov | coveralls; fi",
"test:flat": "npm run test:flat:build && karma start test/flat.conf.js",
"test:flat:build": "npm run test:flat:build:ui && npm run test:flat:build:tests",
"test:flat:build:tests": "node test/integration/index.js",
"test:flat:build:states": "node development/genStates.js",
"test:flat:build:ui": "npm run test:flat:build:states && browserify ./mock-dev.js -o ./development/bundle.js",
"test:mascara": "npm run test:mascara:build && karma start test/mascara.conf.js",
"test:mascara:build": "mkdir -p dist/mascara && npm run test:mascara:build:ui && npm run test:mascara:build:background && npm run test:mascara:build:tests",
"test:mascara:build:ui": "browserify mascara/test/test-ui.js -o dist/mascara/ui.js",
"test:mascara:build:background": "browserify mascara/src/background.js -o dist/mascara/background.js",
"test:mascara:build:tests": "browserify test/integration/lib/first-time.js -o dist/mascara/tests.js",
"lint": "gulp lint",
"disc": "gulp disc --debug",
"announce": "node development/announcer.js",
"generateNotice": "node notices/notice-generator.js",
"deleteNotice": "node notices/notice-delete.js",
"mascara": "node ./mascara/example/server",
"buildMascaraCi": "browserify mascara/test/window-load.js -o mascara/test/bundle.js",
"buildMascaraSWCi": "browserify mascara/src/background.js -o mascara/test/background.js",
"mascaraCi": "npm run buildMascaraCi && npm run buildMascaraSWCi && node mascara/test/index.js",
"testMascara": "cd mascara/test && npm run mascaraCi && testem ci -P 3"
"deleteNotice": "node notices/notice-delete.js"
},
"browserify": {
"transform": [
@ -69,11 +71,12 @@
"eth-bin-to-ops": "^1.0.1",
"eth-contract-metadata": "^1.1.4",
"eth-hd-keyring": "^1.1.1",
"eth-json-rpc-filters": "^1.1.0",
"eth-phishing-detect": "^1.1.4",
"eth-query": "^2.1.2",
"eth-sig-util": "^1.2.2",
"eth-simple-keyring": "^1.1.1",
"eth-token-tracker": "^1.1.3",
"eth-token-tracker": "^1.1.4",
"ethereumjs-tx": "^1.3.0",
"ethereumjs-util": "github:ethereumjs/ethereumjs-util#ac5d0908536b447083ea422b435da27f26615de9",
"ethereumjs-wallet": "^0.6.0",
@ -93,12 +96,15 @@
"iframe-stream": "^3.0.0",
"inject-css": "^0.1.1",
"jazzicon": "^1.2.0",
"json-rpc-engine": "^3.2.0",
"json-rpc-middleware-stream": "^1.0.1",
"loglevel": "^1.4.1",
"metamask-logo": "^2.1.2",
"mississippi": "^1.2.0",
"mkdirp": "^0.5.1",
"multiplex": "^6.7.0",
"number-to-bn": "^1.7.0",
"obj-multiplex": "^1.0.0",
"obs-store": "^2.3.1",
"once": "^1.3.3",
"ping-pong-stream": "^1.0.0",
@ -119,11 +125,11 @@
"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",
"readable-stream": "^2.3.3",
"redux": "^3.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.2.0",
"request-promise": "^4.1.1",
"request-promise": "^4.2.1",
"sandwich-expando": "^1.0.5",
"semaphore": "^1.0.5",
"sw-stream": "^2.0.0",
@ -138,7 +144,7 @@
},
"devDependencies": {
"babel-core": "^6.24.1",
"babel-eslint": "^7.2.3",
"babel-eslint": "^8.0.0",
"babel-plugin-transform-async-to-generator": "^6.24.1",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.23.0",
@ -171,7 +177,11 @@
"jsdom": "^11.1.0",
"jsdom-global": "^3.0.2",
"jshint-stylish": "~2.2.1",
"json-rpc-engine": "^3.0.1",
"karma": "^1.7.1",
"karma-chrome-launcher": "^2.2.0",
"karma-cli": "^1.0.1",
"karma-firefox-launcher": "^1.0.1",
"karma-qunit": "^1.2.1",
"lodash.assign": "^4.0.6",
"mocha": "^3.4.2",
"mocha-eslint": "^4.0.0",
@ -186,7 +196,7 @@
"react-addons-test-utils": "^15.5.1",
"react-test-renderer": "^15.5.4",
"react-testutils-additions": "^15.2.0",
"sinon": "^3.2.0",
"sinon": "^4.0.0",
"tape": "^4.5.1",
"testem": "^1.10.3",
"uglifyify": "^4.0.2",

@ -0,0 +1,59 @@
// Karma configuration
// Generated on Mon Sep 11 2017 18:45:48 GMT-0700 (PDT)
module.exports = function(config) {
return {
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: process.cwd(),
browserConsoleLogOptions: {
terminal: false,
},
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['qunit'],
// list of files / patterns to load in the browser
files: [
'test/integration/jquery-3.1.0.min.js',
{ pattern: 'dist/chrome/images/**/*.*', watched: false, included: false, served: true },
{ pattern: 'dist/chrome/fonts/**/*.*', watched: false, included: false, served: true },
],
proxies: {
'/images/': '/base/dist/chrome/images/',
'/fonts/': '/base/dist/chrome/fonts/',
},
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress'],
// web server port
port: 9876,
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['Chrome', 'Firefox'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: true,
// Concurrency level
// how many browser should be started simultaneous
concurrency: Infinity
}
}

@ -0,0 +1,8 @@
const getBaseConfig = require('./base.conf.js')
module.exports = function(config) {
const settings = getBaseConfig(config)
settings.files.push('development/bundle.js')
settings.files.push('test/integration/bundle.js')
config.set(settings)
}

@ -1,7 +0,0 @@
function wait(time) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
resolve()
}, time * 3 || 1500)
})
}

@ -1,5 +1,6 @@
const fs = require('fs')
const path = require('path')
const pump = require('pump')
const browserify = require('browserify')
const tests = fs.readdirSync(path.join(__dirname, 'lib'))
const bundlePath = path.join(__dirname, 'bundle.js')
@ -9,11 +10,17 @@ const b = browserify()
const writeStream = fs.createWriteStream(bundlePath)
tests.forEach(function (fileName) {
b.add(path.join(__dirname, 'lib', fileName))
const filePath = path.join(__dirname, 'lib', fileName)
console.log(`bundling test "${filePath}"`)
b.add(filePath)
})
b.bundle()
.pipe(writeStream)
.on('error', (err) => {
throw err
})
pump(
b.bundle(),
writeStream,
(err) => {
if (err) throw err
console.log(`Integration test build completed: "${bundlePath}"`)
process.exit(0)
}
)

@ -2,125 +2,130 @@ const PASSWORD = 'password123'
QUnit.module('first time usage')
QUnit.test('render init screen', function (assert) {
var done = assert.async()
let app
QUnit.test('render init screen', (assert) => {
const done = assert.async()
runFirstTimeUsageTest(assert).then(done).catch((err) => {
assert.notOk(err, `Error was thrown: ${err.stack}`)
done()
})
})
wait().then(function() {
app = $('iframe').contents().find('#app-content .mock-app-root')
async function runFirstTimeUsageTest(assert, done) {
let waitTime = 0
if (window.METAMASK_PLATFORM_TYPE === 'mascara') waitTime = 4000
await timeout(waitTime)
const recurseNotices = function () {
let button = app.find('button')
const app = $('#app-content')
// recurse notices
while (true) {
const button = app.find('button')
if (button.html() === 'Accept') {
let termsPage = app.find('.markdown')[0]
// still notices to accept
const termsPage = app.find('.markdown')[0]
termsPage.scrollTop = termsPage.scrollHeight
return wait().then(() => {
await timeout()
console.log('Clearing notice')
button.click()
return wait()
}).then(() => {
return recurseNotices()
})
await timeout()
} else {
return wait()
// exit loop
console.log('No more notices...')
break
}
}
return recurseNotices()
}).then(function() {
await timeout()
// Scroll through terms
var title = app.find('h1').text()
const title = app.find('h1').text()
assert.equal(title, 'MetaMask', 'title screen')
// enter password
var pwBox = app.find('#password-box')[0]
var confBox = app.find('#password-box-confirm')[0]
const pwBox = app.find('#password-box')[0]
const confBox = app.find('#password-box-confirm')[0]
pwBox.value = PASSWORD
confBox.value = PASSWORD
return wait()
}).then(function() {
await timeout()
// create vault
var createButton = app.find('button.primary')[0]
const createButton = app.find('button.primary')[0]
createButton.click()
return wait(1500)
}).then(function() {
await timeout(3000)
var created = app.find('h3')[0]
const created = app.find('h3')[0]
assert.equal(created.textContent, 'Vault Created', 'Vault created screen')
// Agree button
var button = app.find('button')[0]
const button = app.find('button')[0]
assert.ok(button, 'button present')
button.click()
return wait(1000)
}).then(function() {
await timeout(1000)
var detail = app.find('.account-detail-section')[0]
const detail = app.find('.account-detail-section')[0]
assert.ok(detail, 'Account detail section loaded.')
var sandwich = app.find('.sandwich-expando')[0]
const sandwich = app.find('.sandwich-expando')[0]
sandwich.click()
return wait()
}).then(function() {
await timeout()
var sandwich = app.find('.menu-droppo')[0]
var children = sandwich.children
var lock = children[children.length - 2]
const menu = app.find('.menu-droppo')[0]
const children = menu.children
const lock = children[children.length - 2]
assert.ok(lock, 'Lock menu item found')
lock.click()
return wait(1000)
}).then(function() {
await timeout(1000)
var pwBox = app.find('#password-box')[0]
pwBox.value = PASSWORD
const pwBox2 = app.find('#password-box')[0]
pwBox2.value = PASSWORD
var createButton = app.find('button.primary')[0]
createButton.click()
const createButton2 = app.find('button.primary')[0]
createButton2.click()
return wait(1000)
}).then(function() {
await timeout(1000)
var detail = app.find('.account-detail-section')[0]
assert.ok(detail, 'Account detail section loaded again.')
const detail2 = app.find('.account-detail-section')[0]
assert.ok(detail2, 'Account detail section loaded again.')
return wait()
}).then(function (){
await timeout()
var qrButton = app.find('.fa.fa-ellipsis-h')[0] // open account settings dropdown
// open account settings dropdown
const qrButton = app.find('.fa.fa-ellipsis-h')[0]
qrButton.click()
return wait(1000)
}).then(function (){
await timeout(1000)
var qrButton = app.find('.dropdown-menu-item')[1] // qr code item
qrButton.click()
// qr code item
const qrButton2 = app.find('.dropdown-menu-item')[1]
qrButton2.click()
return wait(1000)
}).then(function (){
await timeout(1000)
var qrHeader = app.find('.qr-header')[0]
var qrContainer = app.find('#qr-container')[0]
const qrHeader = app.find('.qr-header')[0]
const qrContainer = app.find('#qr-container')[0]
assert.equal(qrHeader.textContent, 'Account 1', 'Should show account label.')
assert.ok(qrContainer, 'QR Container found')
return wait()
}).then(function (){
await timeout()
var networkMenu = app.find('.network-indicator')[0]
const networkMenu = app.find('.network-indicator')[0]
networkMenu.click()
return wait()
}).then(function (){
await timeout()
var networkMenu = app.find('.network-indicator')[0]
var children = networkMenu.children
children.length[3]
assert.ok(children, 'All network options present')
const networkMenu2 = app.find('.network-indicator')[0]
const children2 = networkMenu2.children
children2.length[3]
assert.ok(children2, 'All network options present')
}
done()
function timeout (time) {
return new Promise((resolve, reject) => {
setTimeout(resolve, time || 1500)
})
})
}

@ -29,4 +29,8 @@ module.exports = {
return 'WHADDASALT!'
},
getRandomValues () {
return 'SOO RANDO!!!1'
}
}

@ -0,0 +1,17 @@
const getBaseConfig = require('./base.conf.js')
module.exports = function(config) {
const settings = getBaseConfig(config)
// ui and tests
settings.files.push('dist/mascara/ui.js')
settings.files.push('dist/mascara/tests.js')
// service worker background
settings.files.push({ pattern: 'dist/mascara/background.js', watched: false, included: false, served: true }),
settings.proxies['/background.js'] = '/base/dist/mascara/background.js'
// use this to keep the browser open for debugging
settings.browserNoActivityTimeout = 10000000
config.set(settings)
}

@ -35,10 +35,15 @@ describe('PendingTx', function () {
const renderer = ReactTestUtils.createRenderer()
const newGasPrice = '0x77359400'
const computedBalances = {}
computedBalances[Object.keys(identities)[0]] = {
ethBalance: '0x00000000000000056bc75e2d63100000',
}
const props = {
identities,
accounts: identities,
txData,
computedBalances,
sendTransaction: (txMeta, event) => {
// Assert changes:
const result = ethUtil.addHexPrefix(txMeta.txParams.gasPrice)

@ -24,15 +24,12 @@ describe('KeyringController', function () {
getTxList: () => [],
getUnapprovedTxList: () => [],
},
ethStore: {
accountTracker: {
addAccount (acct) { accounts.push(ethUtil.addHexPrefix(acct)) },
},
encryptor: mockEncryptor,
})
// Stub out the browser crypto for a mock encryptor.
// Browser crypto is tested in the integration test suite.
keyringController.encryptor = mockEncryptor
keyringController.createNewVaultAndKeychain(password)
.then(function (newState) {
newState

@ -162,6 +162,25 @@ describe('Nonce Tracker', function () {
await nonceLock.releaseLock()
})
})
describe('Faq issue 67', function () {
beforeEach(function () {
const txGen = new MockTxGen()
const confirmedTxs = txGen.generate({ status: 'confirmed' }, { count: 64 })
const pendingTxs = txGen.generate({
status: 'submitted',
}, { count: 10 })
// 0x40 is 64 in hex:
nonceTracker = generateNonceTrackerWith(pendingTxs, [], '0x40')
})
it('should return nonce after network nonce', async function () {
this.timeout(15000)
const nonceLock = await nonceTracker.getNonceLock('0x7d3517b0d011698406d6e0aed8453f0be2697926')
assert.equal(nonceLock.nextNonce, '74', `nonce should be 74 got ${nonceLock.nextNonce}`)
await nonceLock.releaseLock()
})
})
})
})

@ -0,0 +1,93 @@
const assert = require('assert')
const PendingBalanceCalculator = require('../../app/scripts/lib/pending-balance-calculator')
const MockTxGen = require('../lib/mock-tx-gen')
const BN = require('ethereumjs-util').BN
let providerResultStub = {}
const zeroBn = new BN(0)
const etherBn = new BN(String(1e18))
const ether = '0x' + etherBn.toString(16)
describe('PendingBalanceCalculator', function () {
let balanceCalculator
describe('#calculateMaxCost(tx)', function () {
it('returns a BN for a given tx value', function () {
const txGen = new MockTxGen()
pendingTxs = txGen.generate({
status: 'submitted',
txParams: {
value: ether,
gasPrice: '0x0',
gas: '0x0',
}
}, { count: 1 })
const balanceCalculator = generateBalanceCalcWith([], zeroBn)
const result = balanceCalculator.calculateMaxCost(pendingTxs[0])
assert.equal(result.toString(), etherBn.toString(), 'computes one ether')
})
it('calculates gas costs as well', function () {
const txGen = new MockTxGen()
pendingTxs = txGen.generate({
status: 'submitted',
txParams: {
value: '0x0',
gasPrice: '0x2',
gas: '0x3',
}
}, { count: 1 })
const balanceCalculator = generateBalanceCalcWith([], zeroBn)
const result = balanceCalculator.calculateMaxCost(pendingTxs[0])
assert.equal(result.toString(), '6', 'computes 6 wei of gas')
})
})
describe('if you have no pending txs and one ether', function () {
beforeEach(function () {
balanceCalculator = generateBalanceCalcWith([], etherBn)
})
it('returns the network balance', async function () {
const result = await balanceCalculator.getBalance()
assert.equal(result, ether, `gave ${result} needed ${ether}`)
})
})
describe('if you have a one ether pending tx and one ether', function () {
beforeEach(function () {
const txGen = new MockTxGen()
pendingTxs = txGen.generate({
status: 'submitted',
txParams: {
value: ether,
gasPrice: '0x0',
gas: '0x0',
}
}, { count: 1 })
balanceCalculator = generateBalanceCalcWith(pendingTxs, etherBn)
})
it('returns the subtracted result', async function () {
const result = await balanceCalculator.getBalance()
assert.equal(result, '0x0', `gave ${result} needed '0x0'`)
return true
})
})
})
function generateBalanceCalcWith (transactions, providerStub = zeroBn) {
const getPendingTransactions = async () => transactions
const getBalance = async () => providerStub
return new PendingBalanceCalculator({
getBalance,
getPendingTransactions,
})
}

@ -27,7 +27,7 @@ describe('Transaction Controller', function () {
networkStore: new ObservableStore(currentNetworkId),
txHistoryLimit: 10,
blockTracker: { getCurrentBlock: noop, on: noop, once: noop },
ethStore: { getState: noop },
accountTracker: { getState: noop },
signTransaction: (ethTx) => new Promise((resolve) => {
ethTx.sign(privKey)
resolve()

@ -1,10 +0,0 @@
launch_in_dev:
- Chrome
- Firefox
launch_in_ci:
- Chrome
- Firefox
framework:
- qunit
before_tests: "npm run buildCiUnits"
test_page: "test/integration/index.html"

@ -61,7 +61,7 @@ const actions = {
var css = MetaMaskUiCss()
injectCss(css)
const container = document.querySelector('#app-content')
const container = document.querySelector('#test-container')
// parse opts
var store = configureStore(states[selectedView])
@ -72,7 +72,7 @@ render(
h(Selector, { actions, selectedKey: selectedView, states, store }),
h('.mock-app-root', {
h('#app-content', {
style: {
height: '500px',
width: '360px',

@ -32,6 +32,7 @@ function mapStateToProps (state) {
currentCurrency: state.metamask.currentCurrency,
currentAccountTab: state.metamask.currentAccountTab,
tokens: state.metamask.tokens,
computedBalances: state.metamask.computedBalances,
}
}
@ -45,7 +46,7 @@ AccountDetailScreen.prototype.render = function () {
var selected = props.address || Object.keys(props.accounts)[0]
var checksumAddress = selected && ethUtil.toChecksumAddress(selected)
var identity = props.identities[selected]
var account = props.accounts[selected]
var account = props.computedBalances[selected]
const { network, conversionRate, currentCurrency } = props
return (
@ -180,7 +181,7 @@ AccountDetailScreen.prototype.render = function () {
}, [
h(EthBalance, {
value: account && account.balance,
value: account && account.ethBalance,
conversionRate,
currentCurrency,
style: {

@ -104,6 +104,7 @@ var actions = {
txError: txError,
nextTx: nextTx,
previousTx: previousTx,
cancelAllTx: cancelAllTx,
viewPendingTx: viewPendingTx,
VIEW_PENDING_TX: 'VIEW_PENDING_TX',
// app messages
@ -457,6 +458,16 @@ function cancelTx (txData) {
}
}
function cancelAllTx (txsData) {
return (dispatch) => {
txsData.forEach((txData, i) => {
background.cancelTransaction(txData.id, () => {
dispatch(actions.completedTx(txData.id))
i === txsData.length - 1 ? dispatch(actions.goHome()) : null
})
})
}
}
//
// initialize screen
//

@ -3,6 +3,8 @@ const Component = require('react').Component
const h = require('react-hyperscript')
const connect = require('react-redux').connect
const actions = require('./actions')
const Tooltip = require('./components/tooltip.js')
const ethUtil = require('ethereumjs-util')
const abi = require('human-standard-token-abi')
@ -15,6 +17,7 @@ module.exports = connect(mapStateToProps)(AddTokenScreen)
function mapStateToProps (state) {
return {
identities: state.metamask.identities,
}
}
@ -64,15 +67,25 @@ AddTokenScreen.prototype.render = function () {
}, [
h('div', [
h('span', {
h(Tooltip, {
position: 'top',
title: 'The contract of the actual token contract. Click for more info.',
}, [
h('a', {
style: { fontWeight: 'bold', paddingRight: '10px'},
}, 'Token Address'),
href: 'https://consensyssupport.happyfox.com/staff/kb/article/24-what-is-a-token-contract-address',
target: '_blank',
}, [
h('span', 'Token Contract Address '),
h('i.fa.fa-question-circle'),
]),
]),
]),
h('section.flex-row.flex-center', [
h('input#token-address', {
name: 'address',
placeholder: 'Token Address',
placeholder: 'Token Contract Address',
onChange: this.tokenAddressDidChange.bind(this),
style: {
width: 'inherit',
@ -171,7 +184,9 @@ AddTokenScreen.prototype.tokenAddressDidChange = function (event) {
AddTokenScreen.prototype.validateInputs = function () {
let msg = ''
const state = this.state
const identitiesList = Object.keys(this.props.identities)
const { address, symbol, decimals } = state
const standardAddress = ethUtil.addHexPrefix(address).toLowerCase()
const validAddress = ethUtil.isValidAddress(address)
if (!validAddress) {
@ -189,7 +204,12 @@ AddTokenScreen.prototype.validateInputs = function () {
msg += 'Symbol must be between 0 and 10 characters.'
}
const isValid = validAddress && validDecimals
const ownAddress = identitiesList.includes(standardAddress)
if (ownAddress) {
msg = 'Personal address detected. Input the token contract address.'
}
const isValid = validAddress && validDecimals && !ownAddress
if (!isValid) {
this.setState({
@ -216,4 +236,3 @@ AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address)
this.setState({ symbol: symbol[0], decimals: decimals[0].toString() })
}
}

@ -42,6 +42,7 @@ function mapStateToProps (state) {
identities,
accounts,
address,
keyrings,
} = state.metamask
const selected = address || Object.keys(accounts)[0]
@ -69,6 +70,7 @@ function mapStateToProps (state) {
// state needed to get account dropdown temporarily rendering from app bar
identities,
selected,
keyrings,
}
}
@ -187,6 +189,7 @@ App.prototype.renderAppBar = function () {
identities: this.props.identities,
selected: this.props.currentView.context,
network: this.props.network,
keyrings: this.props.keyrings,
}, []),
// hamburger

@ -22,12 +22,19 @@ class AccountDropdowns extends Component {
}
renderAccounts () {
const { identities, selected } = this.props
const { identities, selected, keyrings } = this.props
return Object.keys(identities).map((key, index) => {
const identity = identities[key]
const isSelected = identity.address === selected
const simpleAddress = identity.address.substring(2).toLowerCase()
const keyring = keyrings.find((kr) => {
return kr.accounts.includes(simpleAddress) ||
kr.accounts.includes(identity.address)
})
return h(
DropdownMenuItem,
{
@ -51,6 +58,7 @@ class AccountDropdowns extends Component {
},
},
),
this.indicateIfLoose(keyring),
h('span', {
style: {
marginLeft: '20px',
@ -67,6 +75,14 @@ class AccountDropdowns extends Component {
})
}
indicateIfLoose (keyring) {
try { // Sometimes keyrings aren't loaded yet:
const type = keyring.type
const isLoose = type !== 'HD Key Tree'
return isLoose ? h('.keyring-label', 'LOOSE') : null
} catch (e) { return }
}
renderAccountSelector () {
const { actions } = this.props
const { accountSelectorActive } = this.state
@ -145,6 +161,8 @@ class AccountDropdowns extends Component {
)
}
renderAccountOptions () {
const { actions } = this.props
const { optionsMenuActive } = this.state
@ -278,6 +296,7 @@ AccountDropdowns.defaultProps = {
AccountDropdowns.propTypes = {
identities: PropTypes.objectOf(PropTypes.object),
selected: PropTypes.string,
keyrings: PropTypes.array,
}
const mapDispatchToProps = (dispatch) => {

@ -1,6 +1,7 @@
const Component = require('react').Component
const h = require('react-hyperscript')
const inherits = require('util').inherits
const exportAsFile = require('../util').exportAsFile
const copyToClipboard = require('copy-to-clipboard')
const actions = require('../actions')
const ethUtil = require('ethereumjs-util')
@ -20,20 +21,21 @@ function mapStateToProps (state) {
}
ExportAccountView.prototype.render = function () {
var state = this.props
var accountDetail = state.accountDetail
const state = this.props
const accountDetail = state.accountDetail
const nickname = state.identities[state.address].name
if (!accountDetail) return h('div')
var accountExport = accountDetail.accountExport
const accountExport = accountDetail.accountExport
var notExporting = accountExport === 'none'
var exportRequested = accountExport === 'requested'
var accountExported = accountExport === 'completed'
const notExporting = accountExport === 'none'
const exportRequested = accountExport === 'requested'
const accountExported = accountExport === 'completed'
if (notExporting) return h('div')
if (exportRequested) {
var warning = `Export private keys at your own risk.`
const warning = `Export private keys at your own risk.`
return (
h('div', {
style: {
@ -89,6 +91,8 @@ ExportAccountView.prototype.render = function () {
}
if (accountExported) {
const plainKey = ethUtil.stripHexPrefix(accountDetail.privateKey)
return h('div.privateKey', {
style: {
margin: '0 20px',
@ -105,10 +109,16 @@ ExportAccountView.prototype.render = function () {
onClick: function (event) {
copyToClipboard(ethUtil.stripHexPrefix(accountDetail.privateKey))
},
}, ethUtil.stripHexPrefix(accountDetail.privateKey)),
}, plainKey),
h('button', {
onClick: () => this.props.dispatch(actions.backToAccountDetail(this.props.address)),
}, 'Done'),
h('button', {
style: {
marginLeft: '10px',
},
onClick: () => exportAsFile(`MetaMask ${nickname} Private Key`, plainKey),
}, 'Save as File'),
])
}
}
@ -117,6 +127,6 @@ ExportAccountView.prototype.onExportKeyPress = function (event) {
if (event.key !== 'Enter') return
event.preventDefault()
var input = document.getElementById('exportAccount').value
const input = document.getElementById('exportAccount').value
this.props.dispatch(actions.exportAccount(input, this.props.address))
}

@ -32,7 +32,7 @@ class Dropdown extends Component {
'style',
`
li.dropdown-menu-item:hover { color:rgb(225, 225, 225); }
li.dropdown-menu-item { color: rgb(185, 185, 185); }
li.dropdown-menu-item { color: rgb(185, 185, 185); position: relative }
`
),
...children,

@ -22,7 +22,7 @@ Network.prototype.render = function () {
let iconName, hoverText
if (networkNumber === 'loading') {
return h('span', {
return h('span.pointer', {
style: {
display: 'flex',
alignItems: 'center',
@ -37,7 +37,7 @@ Network.prototype.render = function () {
},
src: 'images/loading.svg',
}),
h('i.fa.fa-sort-desc'),
h('i.fa.fa-caret-down'),
])
} else if (providerName === 'mainnet') {
hoverText = 'Main Ethereum Network'
@ -73,7 +73,8 @@ Network.prototype.render = function () {
style: {
color: '#039396',
}},
'Ethereum Main Net'),
'Main Network'),
h('i.fa.fa-caret-down.fa-lg'),
])
case 'ropsten-test-network':
return h('.network-indicator', [
@ -83,6 +84,7 @@ Network.prototype.render = function () {
color: '#ff6666',
}},
'Ropsten Test Net'),
h('i.fa.fa-caret-down.fa-lg'),
])
case 'kovan-test-network':
return h('.network-indicator', [
@ -92,6 +94,7 @@ Network.prototype.render = function () {
color: '#690496',
}},
'Kovan Test Net'),
h('i.fa.fa-caret-down.fa-lg'),
])
case 'rinkeby-test-network':
return h('.network-indicator', [
@ -101,6 +104,7 @@ Network.prototype.render = function () {
color: '#e7a218',
}},
'Rinkeby Test Net'),
h('i.fa.fa-caret-down.fa-lg'),
])
default:
return h('.network-indicator', [
@ -116,6 +120,7 @@ Network.prototype.render = function () {
color: '#AEAEAE',
}},
'Private Network'),
h('i.fa.fa-caret-down.fa-lg'),
])
}
})(),

@ -33,7 +33,7 @@ function PendingTx () {
PendingTx.prototype.render = function () {
const props = this.props
const { currentCurrency, blockGasLimit } = props
const { currentCurrency, blockGasLimit, computedBalances } = props
const conversionRate = props.conversionRate
const txMeta = this.gatherTxMeta()
@ -42,8 +42,8 @@ PendingTx.prototype.render = function () {
// Account Details
const address = txParams.from || props.selectedAddress
const identity = props.identities[address] || { address: address }
const account = props.accounts[address]
const balance = account ? account.balance : '0x0'
const account = computedBalances[address]
const balance = account ? account.ethBalance : '0x0'
// recipient check
const isValidAddress = !txParams.to || util.isValidAddress(txParams.to)
@ -52,7 +52,9 @@ PendingTx.prototype.render = function () {
const gas = txParams.gas
const gasBn = hexToBn(gas)
const gasLimit = new BN(parseInt(blockGasLimit))
const safeGasLimit = this.bnMultiplyByFraction(gasLimit, 19, 20).toString(10)
const safeGasLimitBN = this.bnMultiplyByFraction(gasLimit, 19, 20)
const saferGasLimitBN = this.bnMultiplyByFraction(gasLimit, 18, 20)
const safeGasLimit = safeGasLimitBN.toString(10)
// Gas Price
const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_BN.toString(16)
@ -66,6 +68,10 @@ PendingTx.prototype.render = function () {
const balanceBn = hexToBn(balance)
const insufficientBalance = balanceBn.lt(maxCost)
const dangerousGasLimit = gasBn.gte(saferGasLimitBN)
const gasLimitSpecified = txMeta.gasLimitSpecified
const buyDisabled = insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting
const showRejectAll = props.unconfTxListLength > 1
this.inputs = []
@ -261,11 +267,14 @@ PendingTx.prototype.render = function () {
text-transform: uppercase;
}
`),
h('.cell.row', {
style: {
textAlign: 'center',
},
}, [
txMeta.simulationFails ?
h('.error', {
style: {
marginLeft: 50,
fontSize: '0.9em',
},
}, 'Transaction Error. Exception thrown in contract code.')
@ -274,7 +283,6 @@ PendingTx.prototype.render = function () {
!isValidAddress ?
h('.error', {
style: {
marginLeft: 50,
fontSize: '0.9em',
},
}, 'Recipient address is invalid. Sending this transaction will result in a loss of ETH.')
@ -283,12 +291,21 @@ PendingTx.prototype.render = function () {
insufficientBalance ?
h('span.error', {
style: {
marginLeft: 50,
fontSize: '0.9em',
},
}, 'Insufficient balance for transaction')
: null,
(dangerousGasLimit && !gasLimitSpecified) ?
h('span.error', {
style: {
fontSize: '0.9em',
},
}, 'Gas limit set dangerously high. Approving this transaction is likely to fail.')
: null,
]),
// send + cancel
h('.flex-row.flex-space-around.conf-buttons', {
style: {
@ -297,14 +314,6 @@ PendingTx.prototype.render = function () {
margin: '14px 25px',
},
}, [
insufficientBalance ?
h('button.btn-green', {
onClick: props.buyEth,
}, 'Buy Ether')
: null,
h('button', {
onClick: (event) => {
this.resetGasFields()
@ -312,18 +321,30 @@ PendingTx.prototype.render = function () {
},
}, 'Reset'),
// Accept Button
// Accept Button or Buy Button
insufficientBalance ? h('button.btn-green', { onClick: props.buyEth }, 'Buy Ether') :
h('input.confirm.btn-green', {
type: 'submit',
value: 'SUBMIT',
style: { marginLeft: '10px' },
disabled: insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting,
disabled: buyDisabled,
}),
h('button.cancel.btn-red', {
onClick: props.cancelTransaction,
}, 'Reject'),
]),
showRejectAll ? h('.flex-row.flex-space-around.conf-buttons', {
style: {
display: 'flex',
justifyContent: 'flex-end',
margin: '14px 25px',
},
}, [
h('button.cancel.btn-red', {
onClick: props.cancelAllTransactions,
}, 'Reject All'),
]) : null,
]),
])
)

@ -17,6 +17,6 @@ Tooltip.prototype.render = function () {
return h(ReactTooltip, {
position: position || 'left',
title,
fixed: false,
fixed: true,
}, children)
}

@ -35,7 +35,7 @@ TransactionIcon.prototype.render = function () {
case 'submitted':
return h(Tooltip, {
title: 'Pending',
position: 'bottom',
position: 'right',
}, [
h('i.fa.fa-ellipsis-h', {
style: {

@ -65,7 +65,7 @@ TransactionListItem.prototype.render = function () {
h(Tooltip, {
title: 'Transaction Number',
position: 'bottom',
position: 'right',
}, [
h('span', {
style: {

@ -29,6 +29,7 @@ function mapStateToProps (state) {
conversionRate: state.metamask.conversionRate,
currentCurrency: state.metamask.currentCurrency,
blockGasLimit: state.metamask.currentBlockGasLimit,
computedBalances: state.metamask.computedBalances,
}
}
@ -39,7 +40,7 @@ function ConfirmTxScreen () {
ConfirmTxScreen.prototype.render = function () {
const props = this.props
const { network, provider, unapprovedTxs, currentCurrency,
const { network, provider, unapprovedTxs, currentCurrency, computedBalances,
unapprovedMsgs, unapprovedPersonalMsgs, conversionRate, blockGasLimit } = props
var unconfTxList = txHelper(unapprovedTxs, unapprovedMsgs, unapprovedPersonalMsgs, network)
@ -48,10 +49,11 @@ ConfirmTxScreen.prototype.render = function () {
var txParams = txData.params || {}
var isNotification = isPopupOrNotification() === 'notification'
log.info(`rendering a combined ${unconfTxList.length} unconf msg & txs`)
if (unconfTxList.length === 0) return h(Loading, { isLoading: true })
const unconfTxListLength = unconfTxList.length
return (
h('.flex-column.flex-grow', [
@ -101,10 +103,13 @@ ConfirmTxScreen.prototype.render = function () {
conversionRate,
currentCurrency,
blockGasLimit,
unconfTxListLength,
computedBalances,
// Actions
buyEth: this.buyEth.bind(this, txParams.from || props.selectedAddress),
sendTransaction: this.sendTransaction.bind(this),
cancelTransaction: this.cancelTransaction.bind(this, txData),
cancelAllTransactions: this.cancelAllTransactions.bind(this, unconfTxList),
signMessage: this.signMessage.bind(this, txData),
signPersonalMessage: this.signPersonalMessage.bind(this, txData),
cancelMessage: this.cancelMessage.bind(this, txData),
@ -151,6 +156,12 @@ ConfirmTxScreen.prototype.cancelTransaction = function (txData, event) {
this.props.dispatch(actions.cancelTx(txData))
}
ConfirmTxScreen.prototype.cancelAllTransactions = function (unconfTxList, event) {
this.stopPropagation(event)
event.preventDefault()
this.props.dispatch(actions.cancelAllTx(unconfTxList))
}
ConfirmTxScreen.prototype.signMessage = function (msgData, event) {
log.info('conf-tx.js: signing message')
var params = msgData.msgParams

@ -5,7 +5,8 @@ const connect = require('react-redux').connect
const actions = require('./actions')
const currencies = require('./conversion.json').rows
const validUrl = require('valid-url')
const copyToClipboard = require('copy-to-clipboard')
const exportAsFile = require('./util').exportAsFile
module.exports = connect(mapStateToProps)(ConfigScreen)
@ -110,9 +111,9 @@ ConfigScreen.prototype.render = function () {
alignSelf: 'center',
},
onClick (event) {
copyToClipboard(window.logState())
exportAsFile('MetaMask State Logs', window.logState())
},
}, 'Copy State Logs'),
}, 'Download State Logs'),
]),
h('hr.horizontal-line'),

@ -215,12 +215,13 @@ hr.horizontal-line {
z-index: 1;
font-size: 11px;
background: rgba(255,0,0,0.8);
bottom: -47px;
color: white;
bottom: 0px;
left: -8px;
border-radius: 10px;
height: 20px;
min-width: 20px;
position: relative;
position: absolute;
display: flex;
align-items: center;
justify-content: center;

@ -103,7 +103,7 @@ InfoScreen.prototype.render = function () {
[
h('div.fa.fa-support', [
h('a.info', {
href: 'https://support.metamask.com',
href: 'https://support.metamask.io',
target: '_blank',
}, 'Visit our Support Center'),
]),

@ -3,6 +3,7 @@ const Component = require('react').Component
const connect = require('react-redux').connect
const h = require('react-hyperscript')
const actions = require('../../actions')
const exportAsFile = require('../../util').exportAsFile
module.exports = connect(mapStateToProps)(CreateVaultCompleteScreen)
@ -65,8 +66,17 @@ CreateVaultCompleteScreen.prototype.render = function () {
style: {
margin: '24px',
fontSize: '0.9em',
marginBottom: '10px',
},
}, 'I\'ve copied it somewhere safe'),
h('button.primary', {
onClick: () => exportAsFile(`MetaMask Seed Words`, seed),
style: {
margin: '10px',
fontSize: '0.9em',
},
}, 'Save Seed Words As File'),
])
)
}

@ -262,6 +262,11 @@ SendTransactionScreen.prototype.onSubmit = function () {
return this.props.dispatch(actions.displayWarning(message))
}
if ((util.isInvalidChecksumAddress(recipient))) {
message = 'Recipient address checksum is invalid.'
return this.props.dispatch(actions.displayWarning(message))
}
if ((!util.isValidAddress(recipient) && !txData) || (!recipient && !txData)) {
message = 'Recipient address is invalid.'
return this.props.dispatch(actions.displayWarning(message))

@ -80,7 +80,7 @@ UnlockScreen.prototype.render = function () {
color: 'rgb(247, 134, 28)',
textDecoration: 'underline',
},
}, 'I forgot my password.'),
}, 'Restore from seed phrase'),
]),
])
)

@ -36,6 +36,8 @@ module.exports = {
valueTable: valueTable,
bnTable: bnTable,
isHex: isHex,
exportAsFile: exportAsFile,
isInvalidChecksumAddress,
}
function valuesFor (obj) {
@ -65,6 +67,12 @@ function isValidAddress (address) {
return (isAllOneCase(prefixed) && ethUtil.isValidAddress(prefixed)) || ethUtil.isValidChecksumAddress(prefixed)
}
function isInvalidChecksumAddress (address) {
var prefixed = ethUtil.addHexPrefix(address)
if (address === '0x0000000000000000000000000000000000000000') return false
return !isAllOneCase(prefixed) && !ethUtil.isValidChecksumAddress(prefixed) && ethUtil.isValidAddress(prefixed)
}
function isAllOneCase (address) {
if (!address) return true
var lower = address.toLowerCase()
@ -215,3 +223,18 @@ function readableDate (ms) {
function isHex (str) {
return Boolean(str.match(/^(0x)?[0-9a-fA-F]+$/))
}
function exportAsFile (filename, data) {
// source: https://stackoverflow.com/a/33542499 by Ludovic Feltz
const blob = new Blob([data], {type: 'text/csv'})
if (window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveBlob(blob, filename)
} else {
const elem = window.document.createElement('a')
elem.href = window.URL.createObjectURL(blob)
elem.download = filename
document.body.appendChild(elem)
elem.click()
document.body.removeChild(elem)
}
}

Loading…
Cancel
Save