diff --git a/css/src/main.css b/css/src/main.css index 352e30ad2..7831e8d16 100644 --- a/css/src/main.css +++ b/css/src/main.css @@ -368,6 +368,7 @@ a:hover { .pr {position: relative;} .pa {position: absolute;} .m0 {margin: 0;} +.p0 {padding: 0 !important;} .db {display: block;} .size-12 { font-size: 12px; } .size-14 { font-size: 14px; } @@ -923,8 +924,8 @@ button, .button, p { .addresses .list-addr i { position: absolute; - top: -25px; - left: 6px; + top: -15px; + left: 0; cursor: pointer; } @@ -1194,5 +1195,10 @@ a.text-warning:hover {color: #FD7262;} padding: 50px; } +.collapse { + margin: auto; + max-width: 100%; +} + /*-----------------------------------------------------------------*/ diff --git a/css/src/mobile.css b/css/src/mobile.css index 05e37c635..9deb53219 100644 --- a/css/src/mobile.css +++ b/css/src/mobile.css @@ -102,6 +102,14 @@ border-right: 1px solid #425568; } + .right-small { + border-left: 1px solid #425568; + } + + .right-small a { + color: white; + } + .panel .secret { padding-top: 0.5rem; display: block; @@ -123,6 +131,10 @@ height: 200px; } + .addresses .panel { + padding: 1rem 0.8rem; + } + .btn-copy { display: none; } diff --git a/index.html b/index.html index d3241cec2..570c9f7d6 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,9 @@
+
+ +

diff --git a/js/app.js b/js/app.js index 4f4934cf3..177755b2a 100644 --- a/js/app.js +++ b/js/app.js @@ -13,7 +13,7 @@ if (localConfig) { if (key === 'networkName' && config['forceNetwork']) { return; } - config[name] = value; + config[key] = value; }); } } diff --git a/js/controllers/send.js b/js/controllers/send.js index 3dc673183..a3112a093 100644 --- a/js/controllers/send.js +++ b/js/controllers/send.js @@ -34,7 +34,7 @@ angular.module('copayApp.controllers').controller('SendController', set: function (newValue) { this._alternative = newValue; if (typeof(newValue) === 'number' && $scope.isRateAvailable) { - this._amount = Number.parseFloat( + this._amount = parseFloat( (rateService.fromFiat(newValue, config.alternativeIsoCode) * satToUnit ).toFixed(config.unitDecimals), 10); } else { @@ -52,7 +52,7 @@ angular.module('copayApp.controllers').controller('SendController', set: function (newValue) { this._amount = newValue; if (typeof(newValue) === 'number' && $scope.isRateAvailable) { - this._alternative = Number.parseFloat( + this._alternative = parseFloat( (rateService.toFiat(newValue * config.unitToSatoshi, config.alternativeIsoCode) ).toFixed(2), 10); } else { diff --git a/js/models/core/PrivateKey.js b/js/models/core/PrivateKey.js index 7c220dadd..8df3325c7 100644 --- a/js/models/core/PrivateKey.js +++ b/js/models/core/PrivateKey.js @@ -1,23 +1,45 @@ 'use strict'; +// 62.9% typed (by google's closure-compiler account) var bitcore = require('bitcore'); var HK = bitcore.HierarchicalKey; var WalletKey = bitcore.WalletKey; var networks = bitcore.networks; var util = bitcore.util; +var _ = require('underscore'); +var preconditions = require('preconditions').instance(); var HDPath = require('./HDPath'); +/** + * @desc + * Wrapper for bitcore.HierarchicalKey to be used inside of Copay. + * + * @param {Object} opts + * @param {string} opts.networkName if set to 'testnet', use the test3 bitcoin + * network constants (livenet otherwise) + * @param {string} opts.extendedPrivateKeyString if set, use this private key + * string, othewise create a new + * private key + */ function PrivateKey(opts) { opts = opts || {}; - this.network = opts.networkName === 'testnet' ? - networks.testnet : networks.livenet; + this.network = opts.networkName === 'testnet' ? networks.testnet : networks.livenet; var init = opts.extendedPrivateKeyString || this.network.name; this.bip = new HK(init); this.privateKeyCache = {}; this.publicHex = this.deriveBIP45Branch().eckey.public.toString('hex'); }; +/** + * @desc Retrieve this derivated private key's public key in hexa format + * + * The value returned is calculated using the path from PrivateKey's + * HDParams.IdFullBranch. This key is used to identify the copayer + * (signing messages mostly). + * + * @returns {string} the public key in a hexadecimal string + */ PrivateKey.prototype.getId = function() { if (!this.id) { this.cacheId(); @@ -25,6 +47,15 @@ PrivateKey.prototype.getId = function() { return this.id; }; +/** + * @desc Retrieve this private key's private key in hex format + * + * The value returned is calculated using the path from PrivateKey's + * HDParams.IdFullBranch. This key is used to identify the copayer + * (signing messages mostly). + * + * @returns {string} the private key in a hexadecimal string + */ PrivateKey.prototype.getIdPriv = function() { if (!this.idpriv) { this.cacheId(); @@ -32,6 +63,15 @@ PrivateKey.prototype.getIdPriv = function() { return this.idpriv; }; +/** + * @desc Retrieve this private key's private key + * + * The value returned is calculated using the path from PrivateKey's + * HDParams.IdFullBranch. This key is used to identify the copayer + * (signing messages mostly). + * + * @returns {bitcore.PrivateKey} the private key + */ PrivateKey.prototype.getIdKey = function() { if (!this.idkey) { this.cacheId(); @@ -39,6 +79,11 @@ PrivateKey.prototype.getIdKey = function() { return this.idkey; }; +/** + * @desc Caches the result of deriving IdFullBranch + * + * @private + */ PrivateKey.prototype.cacheId = function() { var path = HDPath.IdFullBranch; var idhk = this.bip.derive(path); @@ -47,54 +92,116 @@ PrivateKey.prototype.cacheId = function() { this.idpriv = idhk.eckey.private.toString('hex'); }; +/** + * @desc Derive the master branch for Copay. + */ PrivateKey.prototype.deriveBIP45Branch = function() { if (!this.bip45Branch) { this.bip45Branch = this.bip.derive(HDPath.BIP45_PUBLIC_PREFIX); } return this.bip45Branch; -} - - -PrivateKey.trim = function(data) { - var opts = {}; - ['networkName', 'extendedPrivateKeyString'].forEach(function(k){ - opts[k] = data[k]; - }); - - return opts; }; +/** + * @desc Returns an object with information needed to rebuild a PrivateKey + * (as most of its properties are derived from the extended private key). + * + * @TODO: Figure out if this is the correct pattern + * This is a static method and is probably used for serialization. + * + * @static + * @param {Object} data + * @param {*} data.networkName - a name for a bitcoin network + * @param {*} data.extendedPrivateKeyString - a bip32 extended private key + * @returns {Object} an object with two properties: networkName and + * extendedPrivateKeyString, taken from the data + * parameter. + */ +PrivateKey.trim = function(data) { + var opts = {}; + ['networkName', 'extendedPrivateKeyString'].forEach(function(k){ + opts[k] = data[k]; + }); + return opts +}; + +/** + * @desc Generate a private Key from a serialized object + * + * @TODO: This method uses PrivateKey.trim but it's actually not needed... + * + * @param {Object} data + * @param {*} data.networkName - a name for a bitcoin network + * @param {*} data.extendedPrivateKeyString - a bip32 extended private key + * @returns {PrivateKey} + */ PrivateKey.fromObj = function(obj) { return new PrivateKey(PrivateKey.trim(obj)); }; +/** + * @desc Serialize a private key, keeping only the data necessary to rebuild it + * + * @returns {Object} + */ PrivateKey.prototype.toObj = function() { return { extendedPrivateKeyString: this.getExtendedPrivateKeyString(), - networkName: this.network.name, + networkName: this.network.name }; }; +/** + * @desc Retrieve a BIP32 extended public key as generated by bitcore + * + * @returns {string} + */ PrivateKey.prototype.getExtendedPublicKeyString = function() { return this.bip.extendedPublicKeyString(); }; +/** + * @desc Retrieve a BIP32 extended private key as generated by bitcore + * + * @returns {string} + */ PrivateKey.prototype.getExtendedPrivateKeyString = function() { return this.bip.extendedPrivateKeyString(); }; +/** + * @desc + * Retrieve a HierarchicalKey derived from the given path as generated by + * bitcore + * @param {string} path - a string for derivation (something like "m/234'/1/2") + * @returns {bitcore.HierarchicalKey} + */ PrivateKey.prototype._getHK = function(path) { - if (typeof path === 'undefined') { + if (_.isUndefined(path)) { return this.bip; } var ret = this.bip.derive(path); return ret; }; +/** + * @desc + * Retrieve an array of WalletKey derived from given paths. {@see PrivateKey#getForPath} + * + * @param {string[]} paths - the paths to derive + * @returns {bitcore.WalletKey[]} - the derived keys + */ PrivateKey.prototype.getForPaths = function(paths) { return paths.map(this.getForPath.bind(this)); }; +/** + * @desc + * Retrieve a WalletKey derived from a path. + * + * @param {string} paths - the path to derive + * @returns {bitcore.WalletKey} - the derived key + */ PrivateKey.prototype.getForPath = function(path) { var pk = this.privateKeyCache[path]; if (!pk) { @@ -110,14 +217,38 @@ PrivateKey.prototype.getForPath = function(path) { return wk; }; +/** + * @desc + * Retrieve a Branch for Copay using the given path + * + * @TODO: Investigate when is this called and if this is really needed + * + * @param {number} index - the index of the key to generate + * @param {boolean} isChange - whether this is a change adderess or a receive + * @param {number} cosigner - the cosigner index + * @return {bitcore.HierarchicalKey} + */ PrivateKey.prototype.get = function(index, isChange, cosigner) { + + // TODO: Add parameter validation? + var path = HDPath.FullBranch(index, isChange, cosigner); return this.getForPath(path); }; +/** + * @desc + * Retrieve multiple branches for Copay up to the received indexes + * + * @TODO: Investigate when is this called and if this is really needed + * + * @param {number} receiveIndex - the number of receive addresses to generate + * @param {number} changeIndex - the number of change addresses to generate + * @param {number} cosigner - the cosigner index + * @return {bitcore.HierarchicalKey} + */ PrivateKey.prototype.getAll = function(receiveIndex, changeIndex, cosigner) { - if (typeof receiveIndex === 'undefined' || typeof changeIndex === 'undefined') - throw new Error('Invalid parameters'); + preconditions.checkArgument(!_.isUndefined(receiveIndex) && !_.isUndefined(changeIndex)); var ret = []; for (var i = 0; i < receiveIndex; i++) { @@ -129,6 +260,4 @@ PrivateKey.prototype.getAll = function(receiveIndex, changeIndex, cosigner) { return ret; }; - - module.exports = PrivateKey; diff --git a/js/models/core/TxProposals.js b/js/models/core/TxProposals.js index 11d9ad3b8..9e33d6eab 100644 --- a/js/models/core/TxProposals.js +++ b/js/models/core/TxProposals.js @@ -44,6 +44,11 @@ TxProposals.prototype.getNtxids = function() { return Object.keys(this.txps); }; +TxProposals.prototype.deleteOne = function(ntxid) { + preconditions.checkState(this.txps[ntxid], 'Unknown TXP: ' + ntxid); + delete this.txps[ntxid]; +}; + TxProposals.prototype.deleteAll = function() { this.txps = {}; }; diff --git a/js/models/core/Wallet.js b/js/models/core/Wallet.js index 1856005a7..3f61b81e0 100644 --- a/js/models/core/Wallet.js +++ b/js/models/core/Wallet.js @@ -1534,6 +1534,57 @@ Wallet.prototype.getUnspent = function(cb) { }); }; +Wallet.prototype.removeTxWithSpentInputs = function(cb) { + var self = this; + + cb = cb || function () {}; + + var txps = []; + var maxRejectCount = this.maxRejectCount(); + for (var ntxid in this.txProposals.txps) { + var txp = this.txProposals.txps[ntxid]; + txp.ntxid = ntxid; + if (txp.isPending(maxRejectCount)) { + txps.push(txp); + } + } + + var inputs = []; + txps.forEach(function (txp) { + txp.builder.utxos.forEach(function (utxo) { + inputs.push({ ntxid: txp.ntxid, txid: utxo.txid, vout: utxo.vout }); + }); + }); + if (inputs.length === 0) + return; + + + var proposalsChanged = false; + this.blockchain.getUnspent(this.getAddressesStr(), function(err, unspentList) { + if (err) return cb(err); + + unspentList.forEach(function (unspent) { + inputs.forEach(function (input) { + input.unspent = input.unspent || (input.txid === unspent.txid && input.vout === unspent.vout); + }); + }); + + inputs.forEach(function (input) { + if (!input.unspent) { + proposalsChanged = true; + self.txProposals.deleteOne(input.ntxid); + } + }); + + if (proposalsChanged) { + self.emit('txProposalsUpdated'); + self.store(); + } + + cb(null); + }); + +}; Wallet.prototype.createTx = function(toAddress, amountSatStr, comment, opts, cb) { var self = this; diff --git a/js/models/network/Async.js b/js/models/network/Async.js index b15ce9087..19cf047aa 100644 --- a/js/models/network/Async.js +++ b/js/models/network/Async.js @@ -340,6 +340,7 @@ Network.prototype.getCopayerIds = function() { Network.prototype.send = function(dest, payload, cb) { + preconditions.checkState(this.socket); preconditions.checkArgument(payload); var self = this; @@ -357,7 +358,8 @@ Network.prototype.send = function(dest, payload, cb) { var to = dest[ii]; if (to == this.copayerId) continue; - log.debug('SEND to: ' + to, this.copayerId, payload); + + log.debug('SEND to: ' + to, this.copayerId, JSON.stringify(payload)); var message = this.encode(to, payload); this.socket.emit('message', message); diff --git a/js/services/controllerUtils.js b/js/services/controllerUtils.js index edbb9129b..a5ecf83d2 100644 --- a/js/services/controllerUtils.js +++ b/js/services/controllerUtils.js @@ -165,6 +165,8 @@ angular.module('copayApp.services') if (!w) return root.onErrorDigest(); if (!w.isReady()) return; + w.removeTxWithSpentInputs(); + $rootScope.balanceByAddr = {}; $rootScope.updatingBalance = true; diff --git a/test/mocks/FakeWallet.js b/test/mocks/FakeWallet.js index 2270c4d64..77b2d04a5 100644 --- a/test/mocks/FakeWallet.js +++ b/test/mocks/FakeWallet.js @@ -98,6 +98,9 @@ FakeWallet.prototype.getBalance = function(cb) { return cb(null, this.balance, this.balanceByAddr, this.safeBalance); }; +FakeWallet.prototype.removeTxWithSpentInputs = function (cb) { +}; + FakeWallet.prototype.setEnc = function(enc) { this.enc = enc; }; diff --git a/test/test.TxProposals.js b/test/test.TxProposals.js index a7a25e727..2cc332e69 100644 --- a/test/test.TxProposals.js +++ b/test/test.TxProposals.js @@ -65,6 +65,21 @@ describe('TxProposals', function() { txps.getNtxids().should.deep.equal(['a','b']); }); }); + describe('#deleteOne', function() { + it('should delete specified ntxid', function() { + var txps = new TxProposals(); + txps.txps = {a:1, b:2}; + txps.deleteOne('a'); + txps.getNtxids().should.deep.equal(['b']); + }); + it('should fail on non-existent ntxid', function() { + var txps = new TxProposals(); + txps.txps = {a:1, b:2}; + (function () { + txps.deleteOne('c'); + }).should.throw('Unknown TXP: c'); + }); + }); describe('#toObj', function() { it('should an object', function() { var txps = TxProposals.fromObj({ diff --git a/test/test.Wallet.js b/test/test.Wallet.js index 20dcfd9d9..4d80895a9 100644 --- a/test/test.Wallet.js +++ b/test/test.Wallet.js @@ -809,6 +809,79 @@ describe('Wallet model', function() { }); }); + describe('removeTxWithSpentInputs', function () { + it('should remove pending TxProposal with spent inputs', function(done) { + var w = cachedCreateW2(); + var utxo = createUTXO(w); + chai.expect(w.getTxProposals().length).to.equal(0); + w.blockchain.fixUnspent(utxo); + w.createTx(toAddress, amountSatStr, null, function(ntxid) { + w.sendTxProposal(ntxid); + chai.expect(w.getTxProposals().length).to.equal(1); + + // Inputs are still available, txp still valid + w.removeTxWithSpentInputs(); + chai.expect(w.getTxProposals().length).to.equal(1); + + // Simulate input spent. txp should be removed from txps list + w.blockchain.fixUnspent([]); + w.removeTxWithSpentInputs(); + chai.expect(w.getTxProposals().length).to.equal(0); + + done(); + }); + }); + + it('should remove pending TxProposal with at least 1 spent input', function(done) { + var w = cachedCreateW2(); + var utxo = [createUTXO(w)[0], createUTXO(w)[0]]; + utxo[0].amount = 80000; + utxo[1].amount = 80000; + utxo[1].vout = 1; + chai.expect(w.getTxProposals().length).to.equal(0); + w.blockchain.fixUnspent(utxo); + w.createTx(toAddress, '100000', null, function(ntxid) { + w.sendTxProposal(ntxid); + chai.expect(w.getTxProposals().length).to.equal(1); + + // Inputs are still available, txp still valid + w.removeTxWithSpentInputs(); + chai.expect(w.getTxProposals().length).to.equal(1); + + // Simulate 1 input spent. txp should be removed from txps list + w.blockchain.fixUnspent([utxo[0]]); + w.removeTxWithSpentInputs(); + chai.expect(w.getTxProposals().length).to.equal(0); + + done(); + }); + }); + + it('should not remove complete TxProposal', function(done) { + var w = cachedCreateW2(); + var utxo = createUTXO(w); + chai.expect(w.getTxProposals().length).to.equal(0); + w.blockchain.fixUnspent(utxo); + w.createTx(toAddress, amountSatStr, null, function(ntxid) { + w.sendTxProposal(ntxid); + chai.expect(w.getTxProposals().length).to.equal(1); + + // Inputs are still available, txp still valid + w.removeTxWithSpentInputs(); + chai.expect(w.getTxProposals().length).to.equal(1); + + // Simulate input spent. txp should be removed from txps list + w.blockchain.fixUnspent([]); + var txp = w.txProposals.get(ntxid); + sinon.stub(txp, 'isPending', function () { return false; }) + w.removeTxWithSpentInputs(); + chai.expect(w.getTxProposals().length).to.equal(1); + + done(); + }); + }); + }); + describe('#send', function() { it('should call this.network.send', function() { var w = cachedCreateW2(); diff --git a/views/addresses.html b/views/addresses.html index 3efde1173..75a1999ed 100644 --- a/views/addresses.html +++ b/views/addresses.html @@ -7,30 +7,29 @@
-
    -
  • - -
    -
    -   - - +
    +
    +
    +
    +   + + + + + change +
    +
    + +
    + + + + + {{addr.balance || 0|noFractionNumber}} {{$root.unitName}} - - change
    - -
    - - - - - {{addr.balance || 0|noFractionNumber}} {{$root.unitName}} - -
    -
  • -
+
Show all diff --git a/views/includes/sidebar-mobile.html b/views/includes/sidebar-mobile.html index d829f773d..bb1d33df5 100644 --- a/views/includes/sidebar-mobile.html +++ b/views/includes/sidebar-mobile.html @@ -6,36 +6,33 @@
-
-
-
- Balance - - - - {{totalBalance || 0 - |noFractionNumber}} {{$root.unitName}} - -
-
- Locked - - - - {{lockedBalance || 0|noFractionNumber}} {{$root.unitName}} -   -
-
-
+
+
+

+ {{$root.wallet.getName()}} + {{$root.wallet.requiredCopayers}}-of-{{$root.wallet.totalCopayers}} +

+
+ Balance + + + + + {{totalBalance || 0 + |noFractionNumber}} {{$root.unitName}} + +
+
+ Locked + + + + {{lockedBalance || 0|noFractionNumber}} {{$root.unitName}} +   +
+
+