diff options
26 files changed, 656 insertions, 500 deletions
diff --git a/app/scripts/background.js b/app/scripts/background.js index f3837a028..f95e194dd 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -1,162 +1,174 @@ const urlUtil = require('url') -const extend = require('xtend') const Dnode = require('dnode') const eos = require('end-of-stream') +const asyncQ = require('async-q') +const Migrator = require('./lib/migrator/') +const migrations = require('./lib/migrations/') +const LocalStorageStore = require('./lib/observable/local-storage') +const synchronizeStore = require('./lib/observable/util/sync') const PortStream = require('./lib/port-stream.js') const notification = require('./lib/notifications.js') const messageManager = require('./lib/message-manager') const setupMultiplex = require('./lib/stream-utils.js').setupMultiplex const MetamaskController = require('./metamask-controller') const extension = require('./lib/extension') +const firstTimeState = require('./first-time-state') const STORAGE_KEY = 'metamask-config' const METAMASK_DEBUG = 'GULP_METAMASK_DEBUG' -var popupIsOpen = false - -const controller = new MetamaskController({ - // User confirmation callbacks: - showUnconfirmedMessage: triggerUi, - unlockAccountMessage: triggerUi, - showUnapprovedTx: triggerUi, - // Persistence Methods: - setData, - loadData, -}) -function triggerUi () { - if (!popupIsOpen) notification.show() -} -// On first install, open a window to MetaMask website to how-it-works. -extension.runtime.onInstalled.addListener(function (details) { - if ((details.reason === 'install') && (!METAMASK_DEBUG)) { - extension.tabs.create({url: 'https://metamask.io/#how-it-works'}) - } -}) +let popupIsOpen = false + +// state persistence +const diskStore = new LocalStorageStore({ storageKey: STORAGE_KEY }) + +// initialization flow +asyncQ.waterfall([ + () => loadStateFromPersistence(), + (initState) => setupController(initState), +]) +.then(() => console.log('MetaMask initialization complete.')) +.catch((err) => { console.error(err) }) // -// connect to other contexts +// State and Persistence // -extension.runtime.onConnect.addListener(connectRemote) -function connectRemote (remotePort) { - var isMetaMaskInternalProcess = remotePort.name === 'popup' || remotePort.name === 'notification' - var portStream = new PortStream(remotePort) - if (isMetaMaskInternalProcess) { - // communication with popup - popupIsOpen = remotePort.name === 'popup' - setupTrustedCommunication(portStream, 'MetaMask', remotePort.name) - } else { - // communication with page - var originDomain = urlUtil.parse(remotePort.sender.url).hostname - setupUntrustedCommunication(portStream, originDomain) - } -} - -function setupUntrustedCommunication (connectionStream, originDomain) { - // setup multiplexing - var mx = setupMultiplex(connectionStream) - // connect features - controller.setupProviderConnection(mx.createStream('provider'), originDomain) - controller.setupPublicConfig(mx.createStream('publicConfig')) +function loadStateFromPersistence() { + // migrations + let migrator = new Migrator({ migrations }) + let initialState = migrator.generateInitialState(firstTimeState) + return asyncQ.waterfall([ + // read from disk + () => Promise.resolve(diskStore.get() || initialState), + // migrate data + (versionedData) => migrator.migrateData(versionedData), + // write to disk + (versionedData) => { + diskStore.put(versionedData) + return Promise.resolve(versionedData) + }, + // resolve to just data + (versionedData) => Promise.resolve(versionedData.data), + ]) } -function setupTrustedCommunication (connectionStream, originDomain) { - // setup multiplexing - var mx = setupMultiplex(connectionStream) - // connect features - setupControllerConnection(mx.createStream('controller')) - controller.setupProviderConnection(mx.createStream('provider'), originDomain) -} +function setupController (initState) { -// -// remote features -// + // + // MetaMask Controller + // -function setupControllerConnection (stream) { - controller.stream = stream - var api = controller.getApi() - var dnode = Dnode(api) - stream.pipe(dnode).pipe(stream) - dnode.on('remote', (remote) => { - // push updates to popup - var sendUpdate = remote.sendUpdate.bind(remote) - controller.on('update', sendUpdate) - // teardown on disconnect - eos(stream, () => { - controller.removeListener('update', sendUpdate) - popupIsOpen = false - }) + const controller = new MetamaskController({ + // User confirmation callbacks: + showUnconfirmedMessage: triggerUi, + unlockAccountMessage: triggerUi, + showUnapprovedTx: triggerUi, + // initial state + initState, }) -} + global.metamaskController = controller -// -// plugin badge text -// + // setup state persistence + synchronizeStore(controller.store, diskStore, (state) => { + let versionedData = diskStore.get() + versionedData.data = state + return versionedData + }) -controller.txManager.on('updateBadge', updateBadge) -updateBadge() - -function updateBadge () { - var label = '' - var unapprovedTxCount = controller.txManager.unapprovedTxCount - var unconfMsgs = messageManager.unconfirmedMsgs() - var unconfMsgLen = Object.keys(unconfMsgs).length - var count = unapprovedTxCount + unconfMsgLen - if (count) { - label = String(count) + // + // connect to other contexts + // + + extension.runtime.onConnect.addListener(connectRemote) + function connectRemote (remotePort) { + var isMetaMaskInternalProcess = remotePort.name === 'popup' || remotePort.name === 'notification' + var portStream = new PortStream(remotePort) + if (isMetaMaskInternalProcess) { + // communication with popup + popupIsOpen = remotePort.name === 'popup' + setupTrustedCommunication(portStream, 'MetaMask', remotePort.name) + } else { + // communication with page + var originDomain = urlUtil.parse(remotePort.sender.url).hostname + setupUntrustedCommunication(portStream, originDomain) + } } - extension.browserAction.setBadgeText({ text: label }) - extension.browserAction.setBadgeBackgroundColor({ color: '#506F8B' }) -} - -// data :: setters/getters -function loadData () { - var oldData = getOldStyleData() - var newData - try { - newData = JSON.parse(window.localStorage[STORAGE_KEY]) - } catch (e) {} + function setupUntrustedCommunication (connectionStream, originDomain) { + // setup multiplexing + var mx = setupMultiplex(connectionStream) + // connect features + controller.setupProviderConnection(mx.createStream('provider'), originDomain) + controller.setupPublicConfig(mx.createStream('publicConfig')) + } - var data = extend({ - meta: { - version: 0, - }, - data: { - config: { - provider: { - type: 'testnet', - }, - }, - }, - }, oldData || null, newData || null) - return data -} + function setupTrustedCommunication (connectionStream, originDomain) { + // setup multiplexing + var mx = setupMultiplex(connectionStream) + // connect features + setupControllerConnection(mx.createStream('controller')) + controller.setupProviderConnection(mx.createStream('provider'), originDomain) + } -function getOldStyleData () { - var config, wallet, seedWords + // + // remote features + // + + function setupControllerConnection (stream) { + controller.stream = stream + var api = controller.getApi() + var dnode = Dnode(api) + stream.pipe(dnode).pipe(stream) + dnode.on('remote', (remote) => { + // push updates to popup + var sendUpdate = remote.sendUpdate.bind(remote) + controller.on('update', sendUpdate) + // teardown on disconnect + eos(stream, () => { + controller.removeListener('update', sendUpdate) + popupIsOpen = false + }) + }) + } - var result = { - meta: { version: 0 }, - data: {}, + // + // User Interface setup + // + + updateBadge() + controller.txManager.on('updateBadge', updateBadge) + + // plugin badge text + function updateBadge () { + var label = '' + var unapprovedTxCount = controller.txManager.unapprovedTxCount + var unconfMsgs = messageManager.unconfirmedMsgs() + var unconfMsgLen = Object.keys(unconfMsgs).length + var count = unapprovedTxCount + unconfMsgLen + if (count) { + label = String(count) + } + extension.browserAction.setBadgeText({ text: label }) + extension.browserAction.setBadgeBackgroundColor({ color: '#506F8B' }) } - try { - config = JSON.parse(window.localStorage['config']) - result.data.config = config - } catch (e) {} - try { - wallet = JSON.parse(window.localStorage['lightwallet']) - result.data.wallet = wallet - } catch (e) {} - try { - seedWords = window.localStorage['seedWords'] - result.data.seedWords = seedWords - } catch (e) {} - - return result + return Promise.resolve() + } -function setData (data) { - window.localStorage[STORAGE_KEY] = JSON.stringify(data) +// +// Etc... +// + +// popup trigger +function triggerUi () { + if (!popupIsOpen) notification.show() } + +// On first install, open a window to MetaMask website to how-it-works. +extension.runtime.onInstalled.addListener(function (details) { + if ((details.reason === 'install') && (!METAMASK_DEBUG)) { + extension.tabs.create({url: 'https://metamask.io/#how-it-works'}) + } +})
\ No newline at end of file diff --git a/app/scripts/first-time-state.js b/app/scripts/first-time-state.js new file mode 100644 index 000000000..3196981ba --- /dev/null +++ b/app/scripts/first-time-state.js @@ -0,0 +1,11 @@ +// +// The default state of MetaMask +// + +module.exports = { + config: { + provider: { + type: 'testnet', + }, + }, +}
\ No newline at end of file diff --git a/app/scripts/lib/config-manager.js b/app/scripts/lib/config-manager.js index e927c78ec..daba8bc7b 100644 --- a/app/scripts/lib/config-manager.js +++ b/app/scripts/lib/config-manager.js @@ -1,6 +1,4 @@ -const Migrator = require('pojo-migrator') const MetamaskConfig = require('../config.js') -const migrations = require('./migrations') const ethUtil = require('ethereumjs-util') const normalize = require('./sig-util').normalize @@ -19,41 +17,18 @@ module.exports = ConfigManager function ConfigManager (opts) { // ConfigManager is observable and will emit updates this._subs = [] - - /* The migrator exported on the config-manager - * has two methods the user should be concerned with: - * - * getData(), which returns the app-consumable data object - * saveData(), which persists the app-consumable data object. - */ - this.migrator = new Migrator({ - - // Migrations must start at version 1 or later. - // They are objects with a `version` number - // and a `migrate` function. - // - // The `migrate` function receives the previous - // config data format, and returns the new one. - migrations: migrations, - - // How to load initial config. - // Includes step on migrating pre-pojo-migrator data. - loadData: opts.loadData, - - // How to persist migrated config. - setData: opts.setData, - }) + this.store = opts.store } ConfigManager.prototype.setConfig = function (config) { - var data = this.migrator.getData() + var data = this.store.get() data.config = config this.setData(data) this._emitUpdates(config) } ConfigManager.prototype.getConfig = function () { - var data = this.migrator.getData() + var data = this.store.get() if ('config' in data) { return data.config } else { @@ -96,15 +71,15 @@ ConfigManager.prototype.getProvider = function () { } ConfigManager.prototype.setData = function (data) { - this.migrator.saveData(data) + this.store.put(data) } ConfigManager.prototype.getData = function () { - return this.migrator.getData() + return this.store.get() } ConfigManager.prototype.setWallet = function (wallet) { - var data = this.migrator.getData() + var data = this.store.get() data.wallet = wallet this.setData(data) } @@ -121,11 +96,11 @@ ConfigManager.prototype.getVault = function () { } ConfigManager.prototype.getKeychains = function () { - return this.migrator.getData().keychains || [] + return this.store.get().keychains || [] } ConfigManager.prototype.setKeychains = function (keychains) { - var data = this.migrator.getData() + var data = this.store.get() data.keychains = keychains this.setData(data) } @@ -142,19 +117,19 @@ ConfigManager.prototype.setSelectedAccount = function (address) { } ConfigManager.prototype.getWallet = function () { - return this.migrator.getData().wallet + return this.store.get().wallet } // Takes a boolean ConfigManager.prototype.setShowSeedWords = function (should) { - var data = this.migrator.getData() + var data = this.store.get() data.showSeedWords = should this.setData(data) } ConfigManager.prototype.getShouldShowSeedWords = function () { - var data = this.migrator.getData() + var data = this.store.get() return data.showSeedWords } @@ -189,7 +164,7 @@ ConfigManager.prototype.getCurrentRpcAddress = function () { } ConfigManager.prototype.setData = function (data) { - this.migrator.saveData(data) + this.store.put(data) } // @@ -197,7 +172,7 @@ ConfigManager.prototype.setData = function (data) { // ConfigManager.prototype.getTxList = function () { - var data = this.migrator.getData() + var data = this.store.get() if (data.transactions !== undefined) { return data.transactions } else { @@ -206,7 +181,7 @@ ConfigManager.prototype.getTxList = function () { } ConfigManager.prototype.setTxList = function (txList) { - var data = this.migrator.getData() + var data = this.store.get() data.transactions = txList this.setData(data) } diff --git a/app/scripts/lib/inpage-provider.js b/app/scripts/lib/inpage-provider.js index 11bd5cc3a..64301be78 100644 --- a/app/scripts/lib/inpage-provider.js +++ b/app/scripts/lib/inpage-provider.js @@ -1,7 +1,7 @@ const Streams = require('mississippi') const StreamProvider = require('web3-stream-provider') const ObjectMultiplex = require('./obj-multiplex') -const RemoteStore = require('./remote-store.js').RemoteStore +const RemoteStore = require('./observable/remote') const createRandomId = require('./random-id') module.exports = MetamaskInpageProvider @@ -72,13 +72,13 @@ MetamaskInpageProvider.prototype.send = function (payload) { case 'eth_accounts': // read from localStorage - selectedAccount = self.publicConfigStore.get('selectedAccount') + selectedAccount = self.publicConfigStore.get().selectedAccount result = selectedAccount ? [selectedAccount] : [] break case 'eth_coinbase': // read from localStorage - selectedAccount = self.publicConfigStore.get('selectedAccount') + selectedAccount = self.publicConfigStore.get().selectedAccount result = selectedAccount || '0x0000000000000000000000000000000000000000' break @@ -117,9 +117,15 @@ MetamaskInpageProvider.prototype.isMetaMask = true function remoteStoreWithLocalStorageCache (storageKey) { // read local cache - var initState = JSON.parse(localStorage[storageKey] || '{}') - var store = new RemoteStore(initState) - // cache the latest state locally + let initState + try { + initState = JSON.parse(localStorage[storageKey] || '{}') + } catch (err) { + initState = {} + } + // intialize store + const store = new RemoteStore(initState) + // write local cache store.subscribe(function (state) { localStorage[storageKey] = JSON.stringify(state) }) diff --git a/app/scripts/lib/migrations.js b/app/scripts/lib/migrations.js deleted file mode 100644 index f026cbe53..000000000 --- a/app/scripts/lib/migrations.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = [ - require('../migrations/002'), - require('../migrations/003'), - require('../migrations/004'), -] diff --git a/app/scripts/lib/migrator/index.js b/app/scripts/lib/migrator/index.js new file mode 100644 index 000000000..ab5a757b3 --- /dev/null +++ b/app/scripts/lib/migrator/index.js @@ -0,0 +1,40 @@ +const asyncQ = require('async-q') + +class Migrator { + + constructor (opts = {}) { + let migrations = opts.migrations || [] + this.migrations = migrations.sort((a, b) => a.version - b.version) + let lastMigration = this.migrations.slice(-1)[0] + // use specified defaultVersion or highest migration version + this.defaultVersion = opts.defaultVersion || (lastMigration && lastMigration.version) || 0 + } + + // run all pending migrations on meta in place + migrateData (versionedData = this.generateInitialState()) { + let remaining = this.migrations.filter(migrationIsPending) + + return ( + asyncQ.eachSeries(remaining, (migration) => migration.migrate(versionedData)) + .then(() => versionedData) + ) + + // migration is "pending" if hit has a higher + // version number than currentVersion + function migrationIsPending(migration) { + return migration.version > versionedData.meta.version + } + } + + generateInitialState (initState) { + return { + meta: { + version: this.defaultVersion, + }, + data: initState, + } + } + +} + +module.exports = Migrator diff --git a/app/scripts/lib/observable/host.js b/app/scripts/lib/observable/host.js new file mode 100644 index 000000000..d1b110503 --- /dev/null +++ b/app/scripts/lib/observable/host.js @@ -0,0 +1,50 @@ +const Dnode = require('dnode') +const ObservableStore = require('./index') +const endOfStream = require('end-of-stream') + +// +// HostStore +// +// plays host to many RemoteStores and sends its state over a stream +// + +class HostStore extends ObservableStore { + + constructor (initState, opts) { + super(initState) + this._opts = opts || {} + } + + createStream () { + const self = this + // setup remotely exposed api + let remoteApi = {} + if (!self._opts.readOnly) { + remoteApi.put = (newState) => self.put(newState) + } + // listen for connection to remote + const dnode = Dnode(remoteApi) + dnode.on('remote', (remote) => { + // setup update subscription lifecycle + const updateHandler = (state) => remote.put(state) + self._onConnect(updateHandler) + endOfStream(dnode, () => self._onDisconnect(updateHandler)) + }) + return dnode + } + + _onConnect (updateHandler) { + // subscribe to updates + this.subscribe(updateHandler) + // send state immediately + updateHandler(this.get()) + } + + _onDisconnect (updateHandler) { + // unsubscribe to updates + this.unsubscribe(updateHandler) + } + +} + +module.exports = HostStore diff --git a/app/scripts/lib/observable/index.js b/app/scripts/lib/observable/index.js new file mode 100644 index 000000000..1ff112e95 --- /dev/null +++ b/app/scripts/lib/observable/index.js @@ -0,0 +1,41 @@ +const EventEmitter = require('events').EventEmitter + +class ObservableStore extends EventEmitter { + + constructor (initialState) { + super() + this._state = initialState + } + + // wrapper around internal get + get () { + return this._state + } + + // wrapper around internal put + put (newState) { + this._put(newState) + } + + // subscribe to changes + subscribe (handler) { + this.on('update', handler) + } + + // unsubscribe to changes + unsubscribe (handler) { + this.removeListener('update', handler) + } + + // + // private + // + + _put (newState) { + this._state = newState + this.emit('update', newState) + } + +} + +module.exports = ObservableStore diff --git a/app/scripts/lib/observable/local-storage.js b/app/scripts/lib/observable/local-storage.js new file mode 100644 index 000000000..6ed3860f6 --- /dev/null +++ b/app/scripts/lib/observable/local-storage.js @@ -0,0 +1,37 @@ +const ObservableStore = require('./index') + +// +// LocalStorageStore +// +// uses localStorage instead of a cache +// + +class LocalStorageStore extends ObservableStore { + + constructor (opts) { + super() + delete this._state + + this._opts = opts || {} + if (!this._opts.storageKey) { + throw new Error('LocalStorageStore - no "storageKey" specified') + } + this._storageKey = this._opts.storageKey + } + + get() { + try { + return JSON.parse(global.localStorage[this._storageKey]) + } catch (err) { + return undefined + } + } + + _put(newState) { + global.localStorage[this._storageKey] = JSON.stringify(newState) + this.emit('update', newState) + } + +} + +module.exports = LocalStorageStore diff --git a/app/scripts/lib/observable/remote.js b/app/scripts/lib/observable/remote.js new file mode 100644 index 000000000..603f6f0b8 --- /dev/null +++ b/app/scripts/lib/observable/remote.js @@ -0,0 +1,51 @@ +const Dnode = require('dnode') +const ObservableStore = require('./index') +const endOfStream = require('end-of-stream') + +// +// RemoteStore +// +// connects to a HostStore and receives its latest state +// + +class RemoteStore extends ObservableStore { + + constructor (initState, opts) { + super(initState) + this._opts = opts || {} + this._remote = null + } + + put (newState) { + if (!this._remote) throw new Error('RemoteStore - "put" called before connection to HostStore') + this._put(newState) + this._remote.put(newState) + } + + createStream () { + const self = this + const dnode = Dnode({ + put: (newState) => self._put(newState), + }) + // listen for connection to remote + dnode.once('remote', (remote) => { + // setup connection lifecycle + self._onConnect(remote) + endOfStream(dnode, () => self._onDisconnect()) + }) + return dnode + } + + _onConnect (remote) { + this._remote = remote + this.emit('connected') + } + + _onDisconnect () { + this._remote = null + this.emit('disconnected') + } + +} + +module.exports = RemoteStore
\ No newline at end of file diff --git a/app/scripts/lib/observable/util/sync.js b/app/scripts/lib/observable/util/sync.js new file mode 100644 index 000000000..c61feb02e --- /dev/null +++ b/app/scripts/lib/observable/util/sync.js @@ -0,0 +1,24 @@ + +// +// synchronizeStore(inStore, outStore, stateTransform) +// +// keeps outStore synchronized with inStore, via an optional stateTransform +// + +module.exports = synchronizeStore + + +function synchronizeStore(inStore, outStore, stateTransform) { + stateTransform = stateTransform || transformNoop + const initState = stateTransform(inStore.get()) + outStore.put(initState) + inStore.subscribe((inState) => { + const outState = stateTransform(inState) + outStore.put(outState) + }) + return outStore +} + +function transformNoop(state) { + return state +}
\ No newline at end of file diff --git a/app/scripts/lib/remote-store.js b/app/scripts/lib/remote-store.js deleted file mode 100644 index fbfab7bad..000000000 --- a/app/scripts/lib/remote-store.js +++ /dev/null @@ -1,97 +0,0 @@ -const Dnode = require('dnode') -const inherits = require('util').inherits - -module.exports = { - HostStore: HostStore, - RemoteStore: RemoteStore, -} - -function BaseStore (initState) { - this._state = initState || {} - this._subs = [] -} - -BaseStore.prototype.set = function (key, value) { - throw Error('Not implemented.') -} - -BaseStore.prototype.get = function (key) { - return this._state[key] -} - -BaseStore.prototype.subscribe = function (fn) { - this._subs.push(fn) - var unsubscribe = this.unsubscribe.bind(this, fn) - return unsubscribe -} - -BaseStore.prototype.unsubscribe = function (fn) { - var index = this._subs.indexOf(fn) - if (index !== -1) this._subs.splice(index, 1) -} - -BaseStore.prototype._emitUpdates = function (state) { - this._subs.forEach(function (handler) { - handler(state) - }) -} - -// -// host -// - -inherits(HostStore, BaseStore) -function HostStore (initState, opts) { - BaseStore.call(this, initState) -} - -HostStore.prototype.set = function (key, value) { - this._state[key] = value - process.nextTick(this._emitUpdates.bind(this, this._state)) -} - -HostStore.prototype.createStream = function () { - var dnode = Dnode({ - // update: this._didUpdate.bind(this), - }) - dnode.on('remote', this._didConnect.bind(this)) - return dnode -} - -HostStore.prototype._didConnect = function (remote) { - this.subscribe(function (state) { - remote.update(state) - }) - remote.update(this._state) -} - -// -// remote -// - -inherits(RemoteStore, BaseStore) -function RemoteStore (initState, opts) { - BaseStore.call(this, initState) - this._remote = null -} - -RemoteStore.prototype.set = function (key, value) { - this._remote.set(key, value) -} - -RemoteStore.prototype.createStream = function () { - var dnode = Dnode({ - update: this._didUpdate.bind(this), - }) - dnode.once('remote', this._didConnect.bind(this)) - return dnode -} - -RemoteStore.prototype._didConnect = function (remote) { - this._remote = remote -} - -RemoteStore.prototype._didUpdate = function (state) { - this._state = state - this._emitUpdates(state) -} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 5c663255a..d3077817d 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -7,27 +7,40 @@ const KeyringController = require('./keyring-controller') const NoticeController = require('./notice-controller') const messageManager = require('./lib/message-manager') const TxManager = require('./transaction-manager') -const HostStore = require('./lib/remote-store.js').HostStore const Web3 = require('web3') const ConfigManager = require('./lib/config-manager') const extension = require('./lib/extension') const autoFaucet = require('./lib/auto-faucet') const nodeify = require('./lib/nodeify') const IdStoreMigrator = require('./lib/idStore-migrator') +const ObservableStore = require('./lib/observable/') +const HostStore = require('./lib/observable/host') +const synchronizeStore = require('./lib/observable/util/sync') const accountImporter = require('./account-import-strategies') + const version = require('../manifest.json').version module.exports = class MetamaskController extends EventEmitter { constructor (opts) { super() - this.state = { network: 'loading' } this.opts = opts - this.configManager = new ConfigManager(opts) + this.state = { network: 'loading' } + + // observable state store + this.store = new ObservableStore(opts.initState) + // config manager + this.configManager = new ConfigManager({ + store: this.store, + }) + // key mgmt this.keyringController = new KeyringController({ configManager: this.configManager, getNetwork: this.getStateNetwork.bind(this), }) + this.keyringController.on('newAccount', (account) => { + autoFaucet(account) + }) // notices this.noticeController = new NoticeController({ configManager: this.configManager, @@ -245,29 +258,20 @@ module.exports = class MetamaskController extends EventEmitter { initPublicConfigStore () { // get init state - var initPublicState = configToPublic(this.configManager.getConfig()) - var publicConfigStore = new HostStore(initPublicState) - - // subscribe to changes - this.configManager.subscribe(function (state) { - storeSetFromObj(publicConfigStore, configToPublic(state)) - }) - - this.keyringController.on('newAccount', (account) => { - autoFaucet(account) - }) - - // config substate - function configToPublic (state) { - return { - selectedAccount: state.selectedAccount, + var initPublicState = this.store.get() + var publicConfigStore = new HostStore(initPublicState, { readOnly: true }) + + // sync publicConfigStore with transform + synchronizeStore(this.store, publicConfigStore, selectPublicState) + + function selectPublicState(state) { + let result = { selectedAccount: undefined } + try { + result.selectedAccount = state.config.selectedAccount + } catch (err) { + console.warn('Error in "selectPublicState": ' + err.message) } - } - // dump obj into store - function storeSetFromObj (store, obj) { - Object.keys(obj).forEach(function (key) { - store.set(key, obj[key]) - }) + return result } return publicConfigStore diff --git a/app/scripts/migrations/002.js b/app/scripts/migrations/002.js index 0b654f825..476b0a43a 100644 --- a/app/scripts/migrations/002.js +++ b/app/scripts/migrations/002.js @@ -1,13 +1,16 @@ +const version = 2 + module.exports = { - version: 2, + version, - migrate: function (data) { + migrate: function (versionedData) { + versionedData.meta.version = version try { - if (data.config.provider.type === 'etherscan') { - data.config.provider.type = 'rpc' - data.config.provider.rpcTarget = 'https://rpc.metamask.io/' + if (versionedData.data.config.provider.type === 'etherscan') { + versionedData.data.config.provider.type = 'rpc' + versionedData.data.config.provider.rpcTarget = 'https://rpc.metamask.io/' } } catch (e) {} - return data + return Promise.resolve(versionedData) }, } diff --git a/app/scripts/migrations/003.js b/app/scripts/migrations/003.js index 617c55c09..eceaeaa4b 100644 --- a/app/scripts/migrations/003.js +++ b/app/scripts/migrations/003.js @@ -1,15 +1,17 @@ -var oldTestRpc = 'https://rawtestrpc.metamask.io/' -var newTestRpc = 'https://testrpc.metamask.io/' +const version = 3 +const oldTestRpc = 'https://rawtestrpc.metamask.io/' +const newTestRpc = 'https://testrpc.metamask.io/' module.exports = { - version: 3, + version, - migrate: function (data) { + migrate: function (versionedData) { + versionedData.meta.version = version try { - if (data.config.provider.rpcTarget === oldTestRpc) { - data.config.provider.rpcTarget = newTestRpc + if (versionedData.data.config.provider.rpcTarget === oldTestRpc) { + versionedData.data.config.provider.rpcTarget = newTestRpc } } catch (e) {} - return data + return Promise.resolve(versionedData) }, } diff --git a/app/scripts/migrations/004.js b/app/scripts/migrations/004.js index 1329a1eed..0f9850208 100644 --- a/app/scripts/migrations/004.js +++ b/app/scripts/migrations/004.js @@ -1,22 +1,25 @@ +const version = 4 + module.exports = { - version: 4, + version, - migrate: function (data) { + migrate: function (versionedData) { + versionedData.meta.version = version try { - if (data.config.provider.type !== 'rpc') return data - switch (data.config.provider.rpcTarget) { + if (versionedData.data.config.provider.type !== 'rpc') return Promise.resolve(versionedData) + switch (versionedData.data.config.provider.rpcTarget) { case 'https://testrpc.metamask.io/': - data.config.provider = { + versionedData.data.config.provider = { type: 'testnet', } break case 'https://rpc.metamask.io/': - data.config.provider = { + versionedData.data.config.provider = { type: 'mainnet', } break } } catch (_) {} - return data + return Promise.resolve(versionedData) }, } diff --git a/app/scripts/migrations/005.js b/app/scripts/migrations/005.js new file mode 100644 index 000000000..4c76b1dcf --- /dev/null +++ b/app/scripts/migrations/005.js @@ -0,0 +1,44 @@ +const version = 5 + +const ObservableStore = require('../../app/scripts/lib/observable/') +const ConfigManager = require('../../app/scripts/lib/config-manager') +const IdentityStoreMigrator = require('../../app/scripts/lib/idStore-migrator') +const KeyringController = require('../../app/scripts/lib/keyring-controller') + +const password = 'obviously not correct' + +module.exports = { + version, + + migrate: function (versionedData) { + versionedData.meta.version = version + + let store = new ObservableStore(versionedData.data) + let configManager = new ConfigManager({ store }) + let idStoreMigrator = new IdentityStoreMigrator({ configManager }) + let keyringController = new KeyringController({ + configManager: configManager, + }) + + // attempt to migrate to multiVault + return idStoreMigrator.migratedVaultForPassword(password) + .then((result) => { + // skip if nothing to migrate + if (!result) return Promise.resolve(versionedData) + delete versionedData.data.wallet + // create new keyrings + const privKeys = result.lostAccounts.map(acct => acct.privateKey) + return Promise.all([ + keyringController.restoreKeyring(result.serialized), + keyringController.restoreKeyring({ type: 'Simple Key Pair', data: privKeys }), + ]).then(() => { + return keyringController.persistAllKeyrings(password) + }).then(() => { + // copy result on to state object + versionedData.data = store.get() + return Promise.resolve(versionedData) + }) + }) + + }, +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js new file mode 100644 index 000000000..d2ac221b9 --- /dev/null +++ b/app/scripts/migrations/index.js @@ -0,0 +1,18 @@ +/* The migrator has two methods the user should be concerned with: + * + * getData(), which returns the app-consumable data object + * saveData(), which persists the app-consumable data object. + */ + +// Migrations must start at version 1 or later. +// They are objects with a `version` number +// and a `migrate` function. +// +// The `migrate` function receives the previous +// config data format, and returns the new one. + +module.exports = [ + require('./002'), + require('./003'), + require('./004'), +] diff --git a/mock-dev.js b/mock-dev.js index 283bc2c79..a404c64b0 100644 --- a/mock-dev.js +++ b/mock-dev.js @@ -12,6 +12,9 @@ * To use, run `npm run mock`. */ +// pollyfill localStorage for non-browser environments +if (!global.localStorage) global.localStorage + const extend = require('xtend') const render = require('react-dom').render const h = require('react-hyperscript') @@ -21,91 +24,62 @@ const actions = require('./ui/app/actions') const states = require('./development/states') const Selector = require('./development/selector') const MetamaskController = require('./app/scripts/metamask-controller') +const firstTimeState = require('./app/scripts/first-time-state') +const LocalStorageStore = require('./app/scripts/lib/observable/local-storage') +const synchronizeStore = require('./app/scripts/lib/observable/util/sync') const extension = require('./development/mockExtension') +const noop = function () {} + +const STORAGE_KEY = 'metamask-config' +// // Query String +// + const qs = require('qs') let queryString = qs.parse(window.location.href.split('#')[1]) let selectedView = queryString.view || 'first time' const firstState = states[selectedView] updateQueryParams(selectedView) +function updateQueryParams(newView) { + queryString.view = newView + const params = qs.stringify(queryString) + window.location.href = window.location.href.split('#')[0] + `#${params}` +} + +// // CSS +// + const MetaMaskUiCss = require('./ui/css') const injectCss = require('inject-css') +// +// MetaMask Controller +// -function updateQueryParams(newView) { - queryString.view = newView - const params = qs.stringify(queryString) - window.location.href = window.location.href.split('#')[0] + `#${params}` +let dataStore = new LocalStorageStore({ storageKey: STORAGE_KEY }) +// initial state for first time users +if (!dataStore.get()) { + dataStore.put(firstTimeState) } -const noop = function () {} const controller = new MetamaskController({ // User confirmation callbacks: showUnconfirmedMessage: noop, unlockAccountMessage: noop, showUnapprovedTx: noop, - // Persistence Methods: - setData, - loadData, + // initial state + initState: dataStore.get(), }) -// Stub out localStorage for non-browser environments -if (!window.localStorage) { - window.localStorage = {} -} -const STORAGE_KEY = 'metamask-config' -function loadData () { - var oldData = getOldStyleData() - var newData - try { - newData = JSON.parse(window.localStorage[STORAGE_KEY]) - } catch (e) {} - - var data = extend({ - meta: { - version: 0, - }, - data: { - config: { - provider: { - type: 'testnet', - }, - }, - }, - }, oldData || null, newData || null) - return data -} +// setup state persistence +synchronizeStore(controller.store, dataStore) -function setData (data) { - window.localStorage[STORAGE_KEY] = JSON.stringify(data) -} - -function getOldStyleData () { - var config, wallet, seedWords - - var result = { - meta: { version: 0 }, - data: {}, - } - - try { - config = JSON.parse(window.localStorage['config']) - result.data.config = config - } catch (e) {} - try { - wallet = JSON.parse(window.localStorage['lightwallet']) - result.data.wallet = wallet - } catch (e) {} - try { - seedWords = window.localStorage['seedWords'] - result.data.seedWords = seedWords - } catch (e) {} - - return result -} +// +// User Interface +// actions._setBackgroundConnection(controller.getApi()) actions.update = function(stateName) { diff --git a/package.json b/package.json index 569cab2a0..833df170a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "async": "^1.5.2", + "async-q": "^0.3.1", "bip39": "^2.2.0", "browser-passworder": "^2.0.3", "browserify-derequire": "^0.9.4", diff --git a/test/integration/lib/idStore-migrator-test.js b/test/integration/lib/idStore-migrator-test.js index 4ae30411d..d95cfb401 100644 --- a/test/integration/lib/idStore-migrator-test.js +++ b/test/integration/lib/idStore-migrator-test.js @@ -1,30 +1,23 @@ -var ConfigManager = require('../../../app/scripts/lib/config-manager') -var IdStoreMigrator = require('../../../app/scripts/lib/idStore-migrator') -var SimpleKeyring = require('../../../app/scripts/keyrings/simple') -var normalize = require('../../../app/scripts/lib/sig-util').normalize +const ObservableStore = require('../../../app/scripts/lib/observable/') +const ConfigManager = require('../../../app/scripts/lib/config-manager') +const IdStoreMigrator = require('../../../app/scripts/lib/idStore-migrator') +const SimpleKeyring = require('../../../app/scripts/keyrings/simple') +const normalize = require('../../../app/scripts/lib/sig-util').normalize -var oldStyleVault = require('../mocks/oldVault.json') -var badStyleVault = require('../mocks/badVault.json') +const oldStyleVault = require('../mocks/oldVault.json').data +const badStyleVault = require('../mocks/badVault.json').data -var STORAGE_KEY = 'metamask-config' -var PASSWORD = '12345678' -var FIRST_ADDRESS = '0x4dd5d356c5A016A220bCD69e82e5AF680a430d00'.toLowerCase() -var SEED = 'fringe damage bounce extend tunnel afraid alert sound all soldier all dinner' - -var BAD_STYLE_FIRST_ADDRESS = '0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9' +const PASSWORD = '12345678' +const FIRST_ADDRESS = '0x4dd5d356c5A016A220bCD69e82e5AF680a430d00'.toLowerCase() +const BAD_STYLE_FIRST_ADDRESS = '0xac39b311dceb2a4b2f5d8461c1cdaf756f4f7ae9' +const SEED = 'fringe damage bounce extend tunnel afraid alert sound all soldier all dinner' QUnit.module('Old Style Vaults', { beforeEach: function () { - window.localStorage[STORAGE_KEY] = JSON.stringify(oldStyleVault) - - this.configManager = new ConfigManager({ - loadData: () => { return JSON.parse(window.localStorage[STORAGE_KEY]) }, - setData: (data) => { window.localStorage[STORAGE_KEY] = JSON.stringify(data) }, - }) - - this.migrator = new IdStoreMigrator({ - configManager: this.configManager, - }) + let managers = managersFromInitState(oldStyleVault) + + this.configManager = managers.configManager + this.migrator = managers.migrator } }) @@ -37,6 +30,7 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { this.migrator.migratedVaultForPassword(PASSWORD) .then((result) => { + assert.ok(result, 'migratedVaultForPassword returned result') const { serialized, lostAccounts } = result assert.equal(serialized.data.mnemonic, SEED, 'seed phrase recovered') assert.equal(lostAccounts.length, 0, 'no lost accounts') @@ -46,16 +40,10 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { QUnit.module('Old Style Vaults with bad HD seed', { beforeEach: function () { - window.localStorage[STORAGE_KEY] = JSON.stringify(badStyleVault) - - this.configManager = new ConfigManager({ - loadData: () => { return JSON.parse(window.localStorage[STORAGE_KEY]) }, - setData: (data) => { window.localStorage[STORAGE_KEY] = JSON.stringify(data) }, - }) - - this.migrator = new IdStoreMigrator({ - configManager: this.configManager, - }) + let managers = managersFromInitState(badStyleVault) + + this.configManager = managers.configManager + this.migrator = managers.migrator } }) @@ -64,6 +52,7 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { this.migrator.migratedVaultForPassword(PASSWORD) .then((result) => { + assert.ok(result, 'migratedVaultForPassword returned result') const { serialized, lostAccounts } = result assert.equal(lostAccounts.length, 1, 'one lost account') @@ -89,3 +78,15 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { }) }) +function managersFromInitState(initState){ + + let configManager = new ConfigManager({ + store: new ObservableStore(initState), + }) + + let migrator = new IdStoreMigrator({ + configManager: configManager, + }) + + return { configManager, migrator } +}
\ No newline at end of file diff --git a/test/lib/mock-config-manager.js b/test/lib/mock-config-manager.js index b79f63090..c62d91da9 100644 --- a/test/lib/mock-config-manager.js +++ b/test/lib/mock-config-manager.js @@ -1,58 +1,11 @@ -var ConfigManager = require('../../app/scripts/lib/config-manager') +const ConfigManager = require('../../app/scripts/lib/config-manager') +const LocalStorageStore = require('../../app/scripts/lib/observable/local-storage') +const firstTimeState = require('../../app/scripts/first-time-state') const STORAGE_KEY = 'metamask-config' -const extend = require('xtend') module.exports = function() { - return new ConfigManager({ loadData, setData }) -} - -function loadData () { - var oldData = getOldStyleData() - var newData - - try { - newData = JSON.parse(window.localStorage[STORAGE_KEY]) - } catch (e) {} - - var data = extend({ - meta: { - version: 0, - }, - data: { - config: { - provider: { - type: 'testnet', - }, - }, - }, - }, oldData || null, newData || null) - return data -} - -function getOldStyleData () { - var config, wallet, seedWords - - var result = { - meta: { version: 0 }, - data: {}, - } - - try { - config = JSON.parse(window.localStorage['config']) - result.data.config = config - } catch (e) {} - try { - wallet = JSON.parse(window.localStorage['lightwallet']) - result.data.wallet = wallet - } catch (e) {} - try { - seedWords = window.localStorage['seedWords'] - result.data.seedWords = seedWords - } catch (e) {} - - return result -} - -function setData (data) { - window.localStorage[STORAGE_KEY] = JSON.stringify(data) -} + let dataStore = new LocalStorageStore({ storageKey: STORAGE_KEY }) + // initial state for first time users + if (!dataStore.get()) dataStore.put(firstTimeState) + return new ConfigManager({ store: dataStore }) +}
\ No newline at end of file diff --git a/test/unit/config-manager-test.js b/test/unit/config-manager-test.js index 77d431d5f..83b242a8b 100644 --- a/test/unit/config-manager-test.js +++ b/test/unit/config-manager-test.js @@ -1,5 +1,8 @@ // polyfill fetch global.fetch = global.fetch || require('isomorphic-fetch') +// pollyfill localStorage support into JSDom +global.localStorage = global.localStorage || polyfillLocalStorage() + const assert = require('assert') const extend = require('xtend') const rp = require('request-promise') @@ -11,7 +14,7 @@ describe('config-manager', function() { var configManager beforeEach(function() { - window.localStorage = {} // Hacking localStorage support into JSDom + global.localStorage.clear() configManager = configManagerGen() }) @@ -132,7 +135,6 @@ describe('config-manager', function() { }) describe('#setConfig', function() { - window.localStorage = {} // Hacking localStorage support into JSDom it('should set the config key', function () { var testConfig = { @@ -238,3 +240,7 @@ describe('config-manager', function() { }) }) }) + +function polyfillLocalStorage(){ + return Object.create({ clear: function(){ global.localStorage = polyfillLocalStorage() } }) +} diff --git a/test/unit/idStore-migration-test.js b/test/unit/idStore-migration-test.js index 54f38fb2f..4adbef740 100644 --- a/test/unit/idStore-migration-test.js +++ b/test/unit/idStore-migration-test.js @@ -3,6 +3,7 @@ const assert = require('assert') const ethUtil = require('ethereumjs-util') const BN = ethUtil.BN const ConfigManager = require('../../app/scripts/lib/config-manager') +const ObservableStore = require('../../app/scripts/lib/observable/') const delegateCallCode = require('../lib/example-code.json').delegateCallCode // The old way: @@ -42,10 +43,9 @@ describe('IdentityStore to KeyringController migration', function() { beforeEach(function(done) { this.sinon = sinon.sandbox.create() window.localStorage = {} // Hacking localStorage support into JSDom - configManager = new ConfigManager({ - loadData, - setData: (d) => { window.localStorage = d } - }) + let store = new ObservableStore(loadData()) + store.subscribe(setData) + configManager = new ConfigManager({ store }) idStore = new IdentityStore({ diff --git a/test/unit/metamask-controller-test.js b/test/unit/metamask-controller-test.js index a6164c9a0..24d9ddd67 100644 --- a/test/unit/metamask-controller-test.js +++ b/test/unit/metamask-controller-test.js @@ -10,9 +10,11 @@ describe('MetaMaskController', function() { showUnconfirmedMessage: noop, unlockAccountMessage: noop, showUnapprovedTx: noop, - setData, - loadData, + // initial state + initState: loadData(), }) + // setup state persistence + controller.store.subscribe(setData) beforeEach(function() { // sinon allows stubbing methods that are easily verified diff --git a/test/unit/migrations-test.js b/test/unit/migrations-test.js index 9ea8d5c5a..715a5feb0 100644 --- a/test/unit/migrations-test.js +++ b/test/unit/migrations-test.js @@ -1,34 +1,34 @@ -var assert = require('assert') -var path = require('path') +const assert = require('assert') +const path = require('path') -var wallet1 = require(path.join('..', 'lib', 'migrations', '001.json')) +const wallet1 = require(path.join('..', 'lib', 'migrations', '001.json')) -var migration2 = require(path.join('..', '..', 'app', 'scripts', 'migrations', '002')) -var migration3 = require(path.join('..', '..', 'app', 'scripts', 'migrations', '003')) -var migration4 = require(path.join('..', '..', 'app', 'scripts', 'migrations', '004')) +const migration2 = require(path.join('..', '..', 'app', 'scripts', 'migrations', '002')) +const migration3 = require(path.join('..', '..', 'app', 'scripts', 'migrations', '003')) +const migration4 = require(path.join('..', '..', 'app', 'scripts', 'migrations', '004')) -describe('wallet1 is migrated successfully', function() { +const oldTestRpc = 'https://rawtestrpc.metamask.io/' +const newTestRpc = 'https://testrpc.metamask.io/' - it('should convert providers', function(done) { +describe('wallet1 is migrated successfully', function() { + it('should convert providers', function() { wallet1.data.config.provider = { type: 'etherscan', rpcTarget: null } - var firstResult = migration2.migrate(wallet1.data) - assert.equal(firstResult.config.provider.type, 'rpc', 'provider should be rpc') - assert.equal(firstResult.config.provider.rpcTarget, 'https://rpc.metamask.io/', 'main provider should be our rpc') - - var oldTestRpc = 'https://rawtestrpc.metamask.io/' - var newTestRpc = 'https://testrpc.metamask.io/' - firstResult.config.provider.rpcTarget = oldTestRpc - - var secondResult = migration3.migrate(firstResult) - assert.equal(secondResult.config.provider.rpcTarget, newTestRpc) - - var thirdResult = migration4.migrate(secondResult) - assert.equal(secondResult.config.provider.rpcTarget, null) - assert.equal(secondResult.config.provider.type, 'testnet') - - done() + return migration2.migrate(wallet1) + .then((firstResult) => { + assert.equal(firstResult.data.config.provider.type, 'rpc', 'provider should be rpc') + assert.equal(firstResult.data.config.provider.rpcTarget, 'https://rpc.metamask.io/', 'main provider should be our rpc') + firstResult.data.config.provider.rpcTarget = oldTestRpc + return migration3.migrate(firstResult) + }).then((secondResult) => { + assert.equal(secondResult.data.config.provider.rpcTarget, newTestRpc) + return migration4.migrate(secondResult) + }).then((thirdResult) => { + assert.equal(thirdResult.data.config.provider.rpcTarget, null) + assert.equal(thirdResult.data.config.provider.type, 'testnet') + }) + }) }) |