'use strict'; angular.module('copayApp.controllers').controller('walletHomeController', function($scope, $rootScope, $interval, $timeout, $filter, $log, $ionicModal, $ionicPopover, notification, txStatus, profileService, lodash, configService, rateService, storageService, bitcore, gettext, gettextCatalog, platformInfo, addressService, ledger, bwcError, confirmDialog, txFormatService, addressbookService, go, feeService, walletService, fingerprintService, nodeWebkit, ongoingProcess) { var isCordova = platformInfo.isCordova; var isWP = platformInfo.isWP; var isAndroid = platformInfo.isAndroid; var isChromeApp = platformInfo.isChromeApp; var self = this; $rootScope.shouldHideMenuBar = false; $rootScope.wpInputFocused = false; var config = configService.getSync(); var configWallet = config.wallet; var walletSettings = configWallet.settings; var ret = {}; // INIT. Global value ret.unitToSatoshi = walletSettings.unitToSatoshi; ret.satToUnit = 1 / ret.unitToSatoshi; ret.unitName = walletSettings.unitName; ret.alternativeIsoCode = walletSettings.alternativeIsoCode; ret.alternativeName = walletSettings.alternativeName; ret.alternativeAmount = 0; ret.unitDecimals = walletSettings.unitDecimals; ret.isCordova = isCordova; ret.addresses = []; ret.isMobile = platformInfo.isMobile; ret.isWindowsPhoneApp = platformInfo.isWP; ret.countDown = null; ret.sendMaxInfo = {}; ret.showAlternative = false; ret.fromInputAmount = null; var vanillaScope = ret; var disableScannerListener = $rootScope.$on('dataScanned', function(event, data) { if (!data) return; self.setForm(data); $rootScope.$emit('Local/SetTab', 'send'); var form = $scope.sendForm; if (form.address.$invalid && !ongoingProcess.get('fetchingPayPro')) { self.resetForm(); self.error = gettext('Could not recognize a valid Bitcoin QR Code'); } }); var disablePaymentUriListener = $rootScope.$on('paymentUri', function(event, uri) { $rootScope.$emit('Local/SetTab', 'send'); $timeout(function() { self.setForm(uri); }, 100); }); var disableAddrListener = $rootScope.$on('Local/AddressIsUsed', function() { self.setAddress(true); }); var disableFocusListener = $rootScope.$on('Local/NewFocusedWalletReady', function() { self.addr = null; self.resetForm(); $scope.search = ''; if (profileService.focusedClient && profileService.focusedClient.isComplete()) { self.setAddress(); self.setSendFormInputs(); } $log.debug('Cleaning WalletHome Instance'); lodash.each(self, function(v, k) { if (lodash.isFunction(v)) return; if (!lodash.isUndefined(vanillaScope[k])) { self[k] = vanillaScope[k]; return; } if (k == 'isRateAvailable') return; delete self[k]; }); }); var disableResumeListener = $rootScope.$on('Local/Resume', function() { // This is needed then the apps go to sleep self.bindTouchDown(); }); var disableTabListener = $rootScope.$on('Local/TabChanged', function(e, tab) { // This will slow down switch, do not add things here! switch (tab) { case 'receive': // just to be sure we have an address self.setAddress(); break; case 'send': self.resetError(); }; }); $scope.$on('$destroy', function() { disableAddrListener(); disableScannerListener(); disablePaymentUriListener(); disableTabListener(); disableFocusListener(); disableResumeListener(); $rootScope.shouldHideMenuBar = false; }); if (isCordova && StatusBar.isVisible) { var backgroundColor = profileService.focusedClient ? profileService.focusedClient.backgroundColor : "#4B6178"; StatusBar.backgroundColorByHexString(backgroundColor); } this.onQrCodeScanned = function(data) { if (data) go.send(); $rootScope.$emit('dataScanned', data); }; rateService.whenAvailable(function() { self.isRateAvailable = true; $rootScope.$digest(); }); var getClipboard = function(cb) { if (!isCordova || platformInfo.isWP) return cb(); window.cordova.plugins.clipboard.paste(function(value) { var fc = profileService.focusedClient; var Address = bitcore.Address; var networkName = fc.credentials.network; if (Address.isValid(value, networkName) && !$scope.newAddress) { return cb(value); } }); }; var handleEncryptedWallet = function(client, cb) { if (!walletService.isEncrypted(client)) return cb(); $rootScope.$emit('Local/NeedsPassword', false, function(err, password) { if (err) return cb(err); return cb(walletService.unlock(client, password)); }); }; var accept_msg = gettextCatalog.getString('Accept'); var cancel_msg = gettextCatalog.getString('Cancel'); var confirm_msg = gettextCatalog.getString('Confirm'); this.openAddressbookModal = function(wallets, address) { $scope.wallets = wallets; $scope.address = address; $scope.self = self; $ionicModal.fromTemplateUrl('views/modals/addressbook.html', { scope: $scope }).then(function(modal) { $scope.addressbookModal = modal; $scope.addressbookModal.show(); }); }; var GLIDERA_LOCK_TIME = 6 * 60 * 60; // isGlidera flag is a security measure so glidera status is not // only determined by the tx.message this.openTxpModal = function(tx, copayers, isGlidera) { $scope.self = self; $scope.tx = tx; $scope.copayers = copayers; $scope.isGlidera = isGlidera; $scope.error = null; $scope.loading = null; $scope.paymentExpired = null; $scope.currentSpendUnconfirmed = configWallet.spendUnconfirmed; $ionicModal.fromTemplateUrl('views/modals/txp-details.html', { scope: $scope }).then(function(modal) { $scope.txpDetailsModal = modal; $scope.txpDetailsModal.show(); }); }; this.setAddress = function(forceNew) { self.addrError = null; var client = profileService.focusedClient; if (!client || !client.isComplete()) return; // Address already set? if (!forceNew && self.addr) { return; } self.generatingAddress = true; $timeout(function() { addressService.getAddress(client.credentials.walletId, forceNew, function(err, addr) { self.generatingAddress = false; if (err) { self.addrError = err; } else { if (addr) self.addr = addr; } $scope.$digest(); }); }); }; this.copyToClipboard = function(addr, $event) { var showPopover = function() { $ionicPopover.fromTemplateUrl('views/includes/copyToClipboard.html', { scope: $scope }).then(function(popover) { $scope.popover = popover; $scope.popover.show($event); }); $scope.close = function() { $scope.popover.hide(); } $timeout(function() { $scope.popover.hide(); //close the popover after 0.7 seconds }, 700); $scope.$on('$destroy', function() { $scope.popover.remove(); }); }; if (isCordova) { window.cordova.plugins.clipboard.copy(addr); window.plugins.toast.showShortCenter(gettextCatalog.getString('Copied to clipboard')); } else if (platformInfo.isNW) { nodeWebkit.writeToClipboard(addr); showPopover($event); } }; this.shareAddress = function(addr) { if (isCordova) { window.plugins.socialsharing.share('bitcoin:' + addr, null, null, null); } }; // Send this.resetError = function() { this.error = this.success = null; }; this.bindTouchDown = function(tries) { var self = this; tries = tries || 0; if (tries > 5) return; var e = document.getElementById('menu-walletHome'); if (!e) return $timeout(function() { self.bindTouchDown(++tries); }, 500); // on touchdown elements $log.debug('Binding touchstart elements...'); ['hamburger', 'menu-walletHome', 'menu-send', 'menu-receive'].forEach(function(id) { var e = document.getElementById(id); if (e) e.addEventListener('touchstart', function() { try { event.preventDefault(); } catch (e) {}; angular.element(e).triggerHandler('click'); }, true); }); } this.hideMenuBar = lodash.debounce(function(hide) { if (hide) { $rootScope.shouldHideMenuBar = true; } else { $rootScope.shouldHideMenuBar = false; } $rootScope.$digest(); }, 100); this.formFocus = function(what) { if (isCordova && this.isWindowsPhoneApp) { this.hideMenuBar(what); } var self = this; if (isCordova && !this.isWindowsPhoneApp && what == 'address') { getClipboard(function(value) { if (value) { document.getElementById("amount").focus(); $timeout(function() { window.plugins.toast.showShortCenter(gettextCatalog.getString('Pasted from clipboard')); self.setForm(value); }, 100); } }); } }; this.setSendFormInputs = function() { var unitToSat = this.unitToSatoshi; var satToUnit = 1 / unitToSat; /** * Setting the two related amounts as properties prevents an infinite * recursion for watches while preserving the original angular updates * */ Object.defineProperty($scope, "_alternative", { get: function() { return $scope.__alternative; }, set: function(newValue) { $scope.__alternative = newValue; if (self.isRateAvailable) { $scope._amount = parseFloat((rateService.fromFiat(newValue, self.alternativeIsoCode) * satToUnit).toFixed(self.unitDecimals), 10); } else { $scope.__amount = null; } }, enumerable: true, configurable: true }); Object.defineProperty($scope, "_amount", { get: function() { return $scope.__amount; }, set: function(newValue) { $scope.__amount = newValue; if (self.isRateAvailable) { $scope.__alternative = parseFloat((rateService.toFiat(newValue * self.unitToSatoshi, self.alternativeIsoCode)).toFixed(2), 10); } else { $scope.__alternative = null; } self.alternativeAmount = $scope.__alternative; self.resetError(); }, enumerable: true, configurable: true }); Object.defineProperty($scope, "_address", { get: function() { return $scope.__address; }, set: function(newValue) { $scope.__address = self.onAddressChange(newValue); if ($scope.sendForm && $scope.sendForm.address.$valid) { self.lockAddress = true; } }, enumerable: true, configurable: true }); var fc = profileService.focusedClient; // ToDo: use a credential's (or fc's) function for this this.hideNote = !fc.credentials.sharedEncryptingKey; }; this.setSendError = function(err) { var fc = profileService.focusedClient; var prefix = fc.credentials.m > 1 ? gettextCatalog.getString('Could not create payment proposal') : gettextCatalog.getString('Could not send payment'); this.error = bwcError.msg(err, prefix); $timeout(function() { $scope.$digest(); }, 1); }; this.setAmount = function(amount, useAlternativeAmount) { $scope.showAlternative = useAlternativeAmount; self.fromInputAmount = true; self.setForm(null, amount, null); }; this.submitForm = function() { if (!$scope._amount || !$scope._address) return; var client = profileService.focusedClient; var unitToSat = this.unitToSatoshi; var currentSpendUnconfirmed = configWallet.spendUnconfirmed; var outputs = []; this.resetError(); if (isCordova && this.isWindowsPhoneApp) $rootScope.shouldHideMenuBar = true; var form = $scope.sendForm; var comment = form.comment.$modelValue; // ToDo: use a credential's (or fc's) function for this if (comment && !client.credentials.sharedEncryptingKey) { var msg = 'Could not add message to imported wallet without shared encrypting key'; $log.warn(msg); return self.setSendError(gettext(msg)); } if (form.amount.$modelValue * unitToSat > Number.MAX_SAFE_INTEGER) { var msg = 'Amount too big'; $log.warn(msg); return self.setSendError(gettext(msg)); }; $timeout(function() { var paypro = self._paypro; var address, amount; address = form.address.$modelValue; amount = parseInt((form.amount.$modelValue * unitToSat).toFixed(0)); outputs.push({ 'toAddress': address, 'amount': amount, 'message': comment }); var txp = {}; if (!lodash.isEmpty(self.sendMaxInfo)) { txp.sendMax = true; txp.inputs = self.sendMaxInfo.inputs; txp.fee = self.sendMaxInfo.fee; } else { txp.amount = amount; } txp.toAddress = address; txp.outputs = outputs; txp.message = comment; txp.payProUrl = paypro ? paypro.url : null; txp.excludeUnconfirmedUtxos = configWallet.spendUnconfirmed ? false : true; txp.feeLevel = walletSettings.feeLevel || 'normal'; ongoingProcess.set('creatingTx', true); walletService.createTx(client, txp, function(err, createdTxp) { ongoingProcess.set('creatingTx', false); if (err) { return self.setSendError(err); } if (!client.canSign() && !client.isPrivKeyExternal()) { $log.info('No signing proposal: No private key'); ongoingProcess.set('sendingTx', true); walletService.publishTx(client, createdTxp, function(err, publishedTxp) { ongoingProcess.set('sendingTx', false); if (err) { return self.setSendError(err); } self.resetForm(); go.walletHome(); var type = txStatus.notify(createdTxp); $scope.openStatusModal(type, createdTxp, function() { return $scope.$emit('Local/TxProposalAction'); }); }); } else { $rootScope.$emit('Local/NeedsConfirmation', createdTxp, function(accept) { if (accept) self.confirmTx(createdTxp); else self.resetForm(); }); } }); }, 100); }; this.confirmTx = function(txp) { var client = profileService.focusedClient; var self = this; fingerprintService.check(client, function(err) { if (err) { return self.setSendError(err); } handleEncryptedWallet(client, function(err) { if (err) { return self.setSendError(err); } ongoingProcess.set('sendingTx', true); walletService.publishTx(client, txp, function(err, publishedTxp) { ongoingProcess.set('sendingTx', false); if (err) { return self.setSendError(err); } ongoingProcess.set('signingTx', true); walletService.signTx(client, publishedTxp, function(err, signedTxp) { ongoingProcess.set('signingTx', false); walletService.lock(client); if (err) { $scope.$emit('Local/TxProposalAction'); return self.setSendError( err.message ? err.message : gettext('The payment was created but could not be completed. Please try again from home screen')); } if (signedTxp.status == 'accepted') { ongoingProcess.set('broadcastingTx', true); walletService.broadcastTx(client, signedTxp, function(err, broadcastedTxp) { ongoingProcess.set('broadcastingTx', false); if (err) { return self.setSendError(err); } self.resetForm(); go.walletHome(); var type = txStatus.notify(broadcastedTxp); $scope.openStatusModal(type, broadcastedTxp, function() { $scope.$emit('Local/TxProposalAction', broadcastedTxp.status == 'broadcasted'); }); }); } else { self.resetForm(); go.walletHome(); var type = txStatus.notify(signedTxp); $scope.openStatusModal(type, signedTxp, function() { $scope.$emit('Local/TxProposalAction'); }); } }); }); }); }); }; $scope.openStatusModal = function(type, txp, cb) { var fc = profileService.focusedClient; $scope.type = type; $scope.tx = txFormatService.processTx(txp); $scope.color = fc.backgroundColor; $scope.cb = cb; $ionicModal.fromTemplateUrl('views/modals/tx-status.html', { scope: $scope, animation: 'slide-in-up' }).then(function(modal) { $scope.txStatusModal = modal; $scope.txStatusModal.show(); }); }; $scope.openSearchModal = function() { var fc = profileService.focusedClient; $scope.color = fc.backgroundColor; $scope.self = self; $ionicModal.fromTemplateUrl('views/modals/search.html', { scope: $scope, focusFirstInput: true }).then(function(modal) { $scope.searchModal = modal; $scope.searchModal.show(); }); }; $scope.openCustomInputAmountModal = function(addr) { var fc = profileService.focusedClient; $scope.color = fc.backgroundColor; $scope.self = self; $scope.addr = addr; $ionicModal.fromTemplateUrl('views/modals/customAmount.html', { scope: $scope }).then(function(modal) { $scope.customAmountModal = modal; $scope.customAmountModal.show(); }); }; $scope.openAmountModal = function(addr) { if (isCordova) $scope.openInputAmountModal(addr); else $scope.openCustomInputAmountModal(addr); }; $scope.openInputAmountModal = function(addr) { var fc = profileService.focusedClient; $scope.color = fc.backgroundColor; $scope.showAlternativeAmount = $scope.showAlternative || null; if ($scope.showAlternativeAmount) { $scope.amount = $scope.sendForm.alternative.$viewValue || null; } else { $scope.amount = $scope.sendForm.amount.$viewValue || null; } $scope.self = self; $scope.addr = addr; $ionicModal.fromTemplateUrl('views/modals/inputAmount.html', { scope: $scope }).then(function(modal) { $scope.inputAmountModal = modal; $scope.inputAmountModal.show(); }); }; this.setForm = function(to, amount, comment) { var form = $scope.sendForm; if (to) { form.address.$setViewValue(to); form.address.$isValid = true; form.address.$render(); this.lockAddress = true; } if (amount) { form.amount.$setViewValue("" + amount); form.amount.$isValid = true; form.amount.$render(); if (!this.fromInputAmount) this.lockAmount = true; this.fromInputAmount = false; } if (comment) { form.comment.$setViewValue(comment); form.comment.$isValid = true; form.comment.$render(); } }; this.resetForm = function() { this.resetError(); this.sendMaxInfo = {}; if (this.countDown) $interval.cancel(this.countDown); this._paypro = null; this.lockAddress = false; this.lockAmount = false; this._amount = this._address = null; var form = $scope.sendForm; if (form && form.amount) { form.amount.$pristine = true; form.amount.$setViewValue(''); form.amount.$render(); form.comment.$setViewValue(''); form.comment.$render(); form.$setPristine(); if (form.address) { form.address.$pristine = true; form.address.$setViewValue(''); form.address.$render(); } } $timeout(function() { $rootScope.$digest(); }, 1); }; this.setFromPayPro = function(uri, cb) { if (!cb) cb = function() {}; var fc = profileService.focusedClient; if (isChromeApp) { this.error = gettext('Payment Protocol not supported on Chrome App'); return cb(true); } var satToUnit = 1 / this.unitToSatoshi; var self = this; /// Get information of payment if using Payment Protocol ongoingProcess.set('fetchingPayPro', true); $log.debug('Fetch PayPro Request...', uri); $timeout(function() { fc.fetchPayPro({ payProUrl: uri, }, function(err, paypro) { ongoingProcess.set('fetchingPayPro', false); if (err) { $log.warn('Could not fetch payment request:', err); self.resetForm(); var msg = err.toString(); if (msg.match('HTTP')) { msg = gettext('Could not fetch payment information'); } self.error = msg; $timeout(function() { $rootScope.$digest(); }, 1); return cb(true); } if (!paypro.verified) { self.resetForm(); $log.warn('Failed to verify payment protocol signatures'); self.error = gettext('Payment Protocol Invalid'); $timeout(function() { $rootScope.$digest(); }, 1); return cb(true); } self._paypro = paypro; self.setForm(paypro.toAddress, (paypro.amount * satToUnit).toFixed(self.unitDecimals), paypro.memo); _paymentTimeControl(paypro.expires); return cb(); }); }, 1); }; function _paymentTimeControl(expirationTime) { self.paymentExpired = false; setExpirationTime(); self.countDown = $interval(function() { setExpirationTime(); }, 1000); function setExpirationTime() { var now = Math.floor(Date.now() / 1000); if (now > expirationTime) { setExpiredValues(); return; } var totalSecs = expirationTime - now; var m = Math.floor(totalSecs / 60); var s = totalSecs % 60; self.remainingTimeStr = ('0' + m).slice(-2) + ":" + ('0' + s).slice(-2); }; function setExpiredValues() { self.paymentExpired = true; self.remainingTimeStr = null; self._paypro = null; self.error = gettext('Cannot sign: The payment request has expired'); if (self.countDown) $interval.cancel(self.countDown); }; }; this.setFromUri = function(uri) { var self = this; function sanitizeUri(uri) { // Fixes when a region uses comma to separate decimals var regex = /[\?\&]amount=(\d+([\,\.]\d+)?)/i; var match = regex.exec(uri); if (!match || match.length === 0) { return uri; } var value = match[0].replace(',', '.'); var newUri = uri.replace(regex, value); return newUri; }; var satToUnit = 1 / this.unitToSatoshi; // URI extensions for Payment Protocol with non-backwards-compatible request if ((/^bitcoin:\?r=[\w+]/).exec(uri)) { uri = decodeURIComponent(uri.replace('bitcoin:?r=', '')); this.setFromPayPro(uri, function(err) { if (err) { return err; } }); } else { uri = sanitizeUri(uri); if (!bitcore.URI.isValid(uri)) { return uri; } var parsed = new bitcore.URI(uri); var addr = parsed.address ? parsed.address.toString() : ''; var message = parsed.message; var amount = parsed.amount ? (parsed.amount.toFixed(0) * satToUnit).toFixed(this.unitDecimals) : 0; if (parsed.r) { this.setFromPayPro(parsed.r, function(err) { if (err && addr && amount) { self.setForm(addr, amount, message); return addr; } }); } else { this.setForm(addr, amount, message); return addr; } } }; this.onAddressChange = function(value) { this.resetError(); if (!value) return ''; if (this._paypro) return value; if (value.indexOf('bitcoin:') === 0) { return this.setFromUri(value); } else if (/^https?:\/\//.test(value)) { return this.setFromPayPro(value); } else { return value; } }; // History function strip(number) { return (parseFloat(number.toPrecision(12))); } this.getUnitName = function() { return this.unitName; }; this.getAlternativeIsoCode = function() { return this.alternativeIsoCode; }; this.openTxModal = function(btx) { var self = this; $scope.btx = lodash.cloneDeep(btx); $scope.self = self; $ionicModal.fromTemplateUrl('views/modals/tx-details.html', { scope: $scope, hideDelay: 500 }).then(function(modal) { $scope.txDetailsModal = modal; $scope.txDetailsModal.show(); }); }; this.hasAction = function(actions, action) { return actions.hasOwnProperty('create'); }; this.sendMax = function(availableBalanceSat) { if (availableBalanceSat == 0) { this.error = gettext("Cannot create transaction. Insufficient funds"); return; } var self = this; var fc = profileService.focusedClient; this.error = null; ongoingProcess.set('calculatingFee', true); $timeout(function() { feeService.getCurrentFeeValue(function(err, feePerKb) { ongoingProcess.set('calculatingFee', false); if (err || !lodash.isNumber(feePerKb)) { self.error = gettext('Could not get fee value'); return; } var opts = {}; opts.feePerKb = feePerKb; opts.returnInputs = true; var config = configService.getSync(); opts.excludeUnconfirmedUtxos = !config.wallet.spendUnconfirmed; ongoingProcess.set('retrivingInputs', true); fc.getSendMaxInfo(opts, function(err, resp) { ongoingProcess.set('retrivingInputs', false); if (err) { self.error = err; $scope.$apply(); return; } if (resp.amount == 0) { self.error = gettext("Not enough funds for fee"); $scope.$apply(); return; } var msg = gettextCatalog.getString("{{fee}} will be deducted for bitcoin networking fees", { fee: profileService.formatAmount(resp.fee) + ' ' + self.unitName }); var warningMsg = verifyExcludedUtxos(); if (!lodash.isEmpty(warningMsg)) msg += '. \n' + warningMsg; confirmDialog.show(msg, function(confirmed) { if (confirmed) { self.sendMaxInfo = resp; var amount = parseFloat((resp.amount * self.satToUnit).toFixed(self.unitDecimals)); self.setForm(null, amount, null); } else { self.resetForm(); } }); function verifyExcludedUtxos() { var warningMsg = []; if (resp.utxosBelowFee > 0) { warningMsg.push(gettextCatalog.getString("Note: a total of {{amountBelowFeeStr}} were excluded. These funds come from UTXOs smaller than the network fee provided.", { amountBelowFeeStr: profileService.formatAmount(resp.amountBelowFee) + ' ' + self.unitName })); } if (resp.utxosAboveMaxSize > 0) { warningMsg.push(gettextCatalog.getString("Note: a total of {{amountAboveMaxSizeStr}} were excluded. The maximum size allowed for a transaction was exceeded", { amountAboveMaxSizeStr: profileService.formatAmount(resp.amountAboveMaxSize) + ' ' + self.unitName })); } return warningMsg.join('\n'); } }); }); }, 10); }; /* Start setup */ lodash.assign(self, vanillaScope); this.bindTouchDown(); if (profileService.focusedClient) { this.setAddress(); this.setSendFormInputs(); } });