diff options
-rw-r--r-- | app/scripts/controllers/balance.js | 60 | ||||
-rw-r--r-- | app/scripts/controllers/balances.js | 64 | ||||
-rw-r--r-- | app/scripts/controllers/transactions.js | 1 | ||||
-rw-r--r-- | app/scripts/lib/pending-balance-calculator.js | 53 | ||||
-rw-r--r-- | app/scripts/metamask-controller.js | 16 | ||||
-rw-r--r-- | test/unit/pending-balance-test.js | 99 | ||||
-rw-r--r-- | ui/app/account-detail.js | 5 | ||||
-rw-r--r-- | ui/app/components/pending-tx.js | 6 | ||||
-rw-r--r-- | ui/app/conf-tx.js | 5 |
9 files changed, 301 insertions, 8 deletions
diff --git a/app/scripts/controllers/balance.js b/app/scripts/controllers/balance.js new file mode 100644 index 000000000..b4e72e751 --- /dev/null +++ b/app/scripts/controllers/balance.js @@ -0,0 +1,60 @@ +const ObservableStore = require('obs-store') +const PendingBalanceCalculator = require('../lib/pending-balance-calculator') +const BN = require('ethereumjs-util').BN + +class BalanceController { + + constructor (opts = {}) { + const { address, ethStore, txController } = opts + this.address = address + this.ethStore = ethStore + this.txController = txController + + const initState = { + ethBalance: undefined, + } + this.store = new ObservableStore(initState) + + this.balanceCalc = new PendingBalanceCalculator({ + getBalance: () => Promise.resolve(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.txController.blockTracker.on('block', update) + } + + _getBalance () { + const store = this.ethStore.getState() + const balances = store.accounts + const entry = balances[this.address] + const balance = entry.balance + return balance ? new BN(balance.substring(2), 16) : undefined + } + + _getPendingTransactions () { + const pending = this.txController.getFilteredTxList({ + from: this.address, + status: 'submitted', + err: undefined, + }) + return Promise.resolve(pending) + } + +} + +module.exports = BalanceController diff --git a/app/scripts/controllers/balances.js b/app/scripts/controllers/balances.js new file mode 100644 index 000000000..89c2ca95d --- /dev/null +++ b/app/scripts/controllers/balances.js @@ -0,0 +1,64 @@ +const ObservableStore = require('obs-store') +const extend = require('xtend') +const BalanceController = require('./balance') + +class BalancesController { + + constructor (opts = {}) { + const { ethStore, txController } = opts + this.ethStore = ethStore + this.txController = txController + + const initState = extend({ + computedBalances: {}, + }, opts.initState) + this.store = new ObservableStore(initState) + this.balances = {} + + this._initBalanceUpdating() + } + + updateAllBalances () { + for (let address in this.balances) { + this.balances[address].updateBalance() + } + } + + _initBalanceUpdating () { + const store = this.ethStore.getState() + this.addAnyAccountsFromStore(store) + this.ethStore.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, + ethStore: this.ethStore, + txController: this.txController, + }) + 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 = BalancesController diff --git a/app/scripts/controllers/transactions.js b/app/scripts/controllers/transactions.js index fb3be6073..59a3f5329 100644 --- a/app/scripts/controllers/transactions.js +++ b/app/scripts/controllers/transactions.js @@ -434,6 +434,7 @@ module.exports = class TransactionController 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) } diff --git a/app/scripts/lib/pending-balance-calculator.js b/app/scripts/lib/pending-balance-calculator.js new file mode 100644 index 000000000..c66bffbbb --- /dev/null +++ b/app/scripts/lib/pending-balance-calculator.js @@ -0,0 +1,53 @@ +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 = results[0] + const pending = results[1] + + if (!balance) return undefined + + const pendingValue = pending.reduce((total, tx) => { + return total.add(this.valueFor(tx)) + }, new BN(0)) + + return `0x${balance.sub(pendingValue).toString(16)}` + } + + valueFor (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 diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index a007d6fc5..02c06ead2 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -20,6 +20,7 @@ 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/balances') const ConfigManager = require('./lib/config-manager') const nodeify = require('./lib/nodeify') const accountImporter = require('./account-import-strategies') @@ -115,6 +116,16 @@ module.exports = class MetamaskController extends EventEmitter { }) this.txController.on('newUnaprovedTx', opts.showUnapprovedTx.bind(opts)) + // computed balances (accounting for pending transactions) + this.balancesController = new BalancesController({ + ethStore: this.ethStore, + txController: this.txController, + }) + this.networkController.on('networkDidChange', () => { + this.balancesController.updateAllBalances() + }) + this.balancesController.updateAllBalances() + // notices this.noticeController = new NoticeController({ initState: initState.NoticeController, @@ -168,6 +179,7 @@ module.exports = class MetamaskController extends EventEmitter { this.networkController.store.subscribe(this.sendUpdate.bind(this)) this.ethStore.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,6 +254,7 @@ 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, @@ -252,6 +265,7 @@ module.exports = class MetamaskController extends EventEmitter { 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(), @@ -647,4 +661,4 @@ module.exports = class MetamaskController extends EventEmitter { return Promise.resolve(rpcTarget) }) } -}
\ No newline at end of file +} diff --git a/test/unit/pending-balance-test.js b/test/unit/pending-balance-test.js new file mode 100644 index 000000000..dde30fecc --- /dev/null +++ b/test/unit/pending-balance-test.js @@ -0,0 +1,99 @@ +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('#valueFor(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.valueFor(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.valueFor(pendingTxs[0]) + assert.equal(result.toString(), '6', 'computes one ether') + }) + }) + + 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 = () => Promise.resolve(transactions) + const getBalance = () => Promise.resolve(providerStub) + providerResultStub.result = providerStub + const provider = { + sendAsync: (_, cb) => { cb(undefined, providerResultStub) }, + _blockTracker: { + getCurrentBlock: () => '0x11b568', + }, + } + return new PendingBalanceCalculator({ + getBalance, + getPendingTransactions, + }) +} + diff --git a/ui/app/account-detail.js b/ui/app/account-detail.js index 02089ecd0..90724dc3f 100644 --- a/ui/app/account-detail.js +++ b/ui/app/account-detail.js @@ -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: { diff --git a/ui/app/components/pending-tx.js b/ui/app/components/pending-tx.js index 3e53d47f9..1cc8daebe 100644 --- a/ui/app/components/pending-tx.js +++ b/ui/app/components/pending-tx.js @@ -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) diff --git a/ui/app/conf-tx.js b/ui/app/conf-tx.js index 1ee4166f7..15fb9a59f 100644 --- a/ui/app/conf-tx.js +++ b/ui/app/conf-tx.js @@ -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,7 +49,6 @@ 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 }) @@ -104,6 +104,7 @@ ConfirmTxScreen.prototype.render = function () { currentCurrency, blockGasLimit, unconfTxListLength, + computedBalances, // Actions buyEth: this.buyEth.bind(this, txParams.from || props.selectedAddress), sendTransaction: this.sendTransaction.bind(this), |