darkwallet/darkwallet

View on GitHub
src/js/frontend/controllers/send.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict';

define(['./module', 'frontend/port', 'darkwallet', 'util/btc', 'dwutil/currencyformat', 'bitcoinjs-lib', 'dwutil/pcodeutils'],
function (controllers, Port, DarkWallet, BtcUtils, CurrencyFormat, Bitcoin, PCodeUtils) {
  controllers.controller('WalletSendCtrl', ['$scope', '$window', 'notify', 'modals', '$wallet', '$timeout', '$history', '$tabs', '_Filter',
      function($scope, $window, notify, modals, $wallet, $timeout, $history, $tabs, _) {
  
  var sendForm = $scope.forms.send;

  $scope.quicksend = {};

  $scope.resetSendForm = function() {
      sendForm.sending = false;
      sendForm.title = '';
      sendForm.propagated = false;
      $scope.sendEnabled = false;
      sendForm.recipients = {
          fields: [
              { address: '', amount: '' }
          ],
          field_proto: { address: '', amount: '' }
      };
      var identity = DarkWallet.getIdentity();
      sendForm.fee = CurrencyFormat.asBtc(identity.wallet.fee);
      
      $scope.quicksend.next = false;
  };
  
  var parseUri = function(uri, recipient) {
    recipient = recipient || 0;
    var pars = BtcUtils.parseURI(uri);
    if (!pars || !pars.address) {
      notify.warning(_('URI not supported'));
    } else {
      sendForm.title = pars.message ? decodeURIComponent(pars.message) : '';
      sendForm.recipients.fields[recipient].address = pars.address;
      if (pars.amount) {
        pars.amount = CurrencyFormat.asSatoshis(pars.amount, 'BTC');
        pars.amount = CurrencyFormat.asBtc(pars.amount);
        sendForm.recipients.fields[recipient].amount = pars.amount;
      }
    }
  };

  $scope.updateBtcFiat = function(field) {
      var identity = DarkWallet.getIdentity();
      var currency = identity.settings.currency;
      var fiatCurrency = identity.settings.fiatCurrency;
      if (field.isFiatShown) {
          field.amount = CurrencyFormat.fiatToBtc(field.fiatAmount, currency, fiatCurrency);
      } else {
          field.fiatAmount = CurrencyFormat.btcToFiat(field.amount, currency, fiatCurrency);
      }
  };

  $scope.setPocket = function(pocket) {
      var identity = DarkWallet.getIdentity();
      if (pocket == 'any') {
          sendForm.sendPocketName = _('Any');
          // TODO: Any is only using default pocket right now.
          sendForm.pocketIndex = 0;
      } else if (typeof pocket == 'number') {
          sendForm.sendPocketName = identity.wallet.pockets.hdPockets[pocket].name;
          sendForm.pocketIndex = pocket;
      } else {
          var fund = identity.wallet.multisig.search({address: pocket});
          sendForm.sendPocketName = fund.name;
          sendForm.pocketIndex = fund.address;
      }
      $scope.validateSendForm();
  };

 
  // Identity ready
  Port.connectNg('wallet', $scope, function(data) {
    if (data.type == 'ready') {
        // Set the default fee
        var identity = DarkWallet.getIdentity();
        initIdentity(identity);
    }
    else if (data.type == 'rename') {
        initialized = data.newName;
        if (sendForm) {
            sendForm.identity = data.newName;
        }
    }
  });

  var onUpdateRadar = function(radar, task, warning) {
      var progressBar = $window.document.getElementById('send-progress');
      var button = $window.document.getElementById('send-button');

      task.radar = radar;

      // Check if we're finished
      if (radar >= 0.75 || warning) {
          // if the controller is still here and button still active, reset
          if (button && button.classList.contains('working')) {
              if (warning) {
                  notify.warning(_(warning));
              } else {
                  if (!sendForm.propagated) {
                      notify.success(_('Transaction finished propagating'));
                  }
              }
              button.classList.remove('working');
          }
          return true;
      }

      // Proceed if radar is updating
      if (button && !button.classList.contains('working')) {
          button.classList.add('working');
      }

      // Progress bar must be updated at the end
      if (progressBar) {
          progressBar.style.width = radar*100 + '%';
      }
  };


  var prepareRecipients = function() {
      var identity = DarkWallet.getIdentity();
      var recipients = [];
      var contacts = [];
      var totalAmount = 0;
      
      if ($scope.quicksend.next) {
          sendForm.recipients.fields = [{
            address: $scope.quicksend.address,
            amount: $scope.quicksend.amount
          }];
      }
      
      sendForm.recipients.fields.forEach(function(recipient) {
          if (!BtcUtils.validateAddress(recipient.address, validAddresses)) {
              return;
          }
          recipient.contact = identity.contacts.findByAddress(recipient.address);
          if (!recipient.contact) {
              recipient.contact = {data: { name: recipient.address, hash: identity.contacts.generateContactHash(recipient.address) }};
          }
          if (!recipient.amount || !recipient.address) {
              return;
          }
          var amount = CurrencyFormat.asSatoshis(recipient.amount);
          totalAmount += amount;
          recipients.push({address: recipient.address, amount: amount});
          // Contacts is like recipients with the extra contact reference, that we can't
          // keep in recipients since it can be serialized into the store
          contacts.push({address: recipient.address, amount: amount, contact: recipient.contact});
      });
      return {amount: totalAmount, recipients: recipients, contacts: contacts};
  };

  $scope.validateSendForm = function() {
      var identity = DarkWallet.getIdentity();
      if ($scope.quicksend.address) {
          $scope.quicksend.contact = identity.contacts.findByAddress($scope.quicksend.address);
          if ($scope.quicksend.contact) {
              $scope.quicksend.next = true;
          }
      }
      if (!$scope.quicksend.address) {
          $scope.evalueTx();
      }
      var spend = prepareRecipients();
      var checkDust = spend.recipients.filter(function(r) { return r.amount < identity.wallet.dust; });
 
      if ((spend.recipients.length > 0) && (spend.amount >= identity.wallet.dust) && (!checkDust.length) && (sendForm.fee >= 0)) {
          $scope.sendEnabled = true;
      } else {
          $scope.sendEnabled = false;
      }
      if ($scope.sendEnabled) {
          $scope.autoAddField();
      }
  };


  var finishMix = function(metadata, amountNote, password) {
      var onMixing = function(err, mixingTask) {
          if (err) {
              if (err.type == 'password') {
                  notify.warning(_(err));
              } else {
                  notify.error(_('Error sending to mixer ({0})', amountNote), _(mixingTask.task.state) + ' ' + _(err));
              }
              sendForm.sending = false;
              $scope.sendEnabled = true;
          } else {
              $scope.resetSendForm();
              notify.note(_('Sent to mixer ({0})', _(mixingTask.task.state)), amountNote);
          }
      };

      var walletService = DarkWallet.service.wallet;
      walletService.mixTransaction(metadata.tx, metadata, password, onMixing);
  };


  var finishSign = function(metadata, amountNote, password) {
      // callback waiting for radar feedback
      var isBroadcasted = false;
      var radarCache = {radar: 0};
      var sendTimeout = 0;
      var timeoutId;

      // Enable sending again
      var enableSending = function(formReset) {
          sendForm.sending = false;
          $scope.sendEnabled = true;
          if (timeoutId) {
              $timeout.cancel(timeoutId);
              timeoutId = undefined;
          }
          if (formReset) {
              $scope.resetSendForm();
          }
      }

      // Callback for broadcasting or signing
      var onBroadcast = function(error, task) {
          console.log("broadcast feedback", error, task);
          if (sendTimeout==6) {
              return;
          }
          if (error) { 
              if (error.type == 'password') {
                  notify.warning(_(error.message));
              } else {
                  notify.error(_('Error broadcasting'), _(error));
              }
              enableSending();
          } else if (task && task.type == 'signatures') {
              notify.note(_('Signatures pending'), _(amountNote))
              enableSending();
              $tabs.updateTabs($history.pocket.type, $history.pocket.tasks);

          } else if (task && task.type == 'brc') {
              console.log("broadcaster feedback!", task);
              if (task.radar) {
                  notify.success(_('Transaction sent'), _(amountNote));
                  isBroadcasted = true;
                  sendForm.propagated = true;
              } else if (!sendForm.propagated) {
                  notify.warning(_('The transaction did not propagate'));
              }
          } else if (task && task.type == 'radar') {
              if (onUpdateRadar(task.radar || 0, radarCache) && timeoutId) {
                  enableSending(true);
                  if (!$scope.$$phase) {
                      $scope.$apply();
                  };
              }
              if (!isBroadcasted) {
                  notify.success(_('Transaction sent'), _(amountNote));
                  isBroadcasted = true;
              }
          }
      };

      // Watchdog timeout checking every 10 sec for the radar
      var onSendTimeout = function() {
          if (sendTimeout == 6) {
              timeoutId = undefined;
              onUpdateRadar(radarCache.radar, radarCache, _('Timeout broadcasting, total: ') + (radarCache.radar*100).toFixed(2) + '%');
              enableSending(sendForm.propagated||radarCache.radar>0.0);
          } else {
              timeoutId = $timeout(function(){onSendTimeout()}, 10000);
              sendTimeout+=1;
              if ([1, 3, 5].indexOf(sendTimeout) != -1 && !sendForm.propagated) {
                  notify.note(_('Broadcasting going slow'), (radarCache.radar*100).toFixed(2) + '%');
              }
          }
      }
      // Timeout to watch out over sending time
      timeoutId = $timeout(function(){onSendTimeout()}, 10000);

      // Sign and send if it worked out
      var walletService = DarkWallet.service.wallet;
      walletService.signTransaction(metadata.tx, metadata, password, onBroadcast, true);
  };

  var onPassword = function(metadata, amountNote, password, pocketType) {
      if (sendForm.mixing && pocketType !== 'multisig') {
          finishMix(metadata, amountNote, password);
      } else {
          finishSign(metadata, amountNote, password);
      }
  };

  $scope.evalueTx = function() {
      // Prepare recipients
      var amount = 0;
      var outs = 0;
      sendForm.recipients.fields.forEach(function(recipient) {
          if (recipient.amount) {
              amount += CurrencyFormat.asSatoshis(recipient.amount);
              outs += 1;
          }
      });
      var totalAmount = amount;
      var pocketIndex = sendForm.pocketIndex;

      var identity = DarkWallet.getIdentity();

      if (totalAmount < identity.wallet.dust) {
          if (totalAmount > 0) {
              $scope.txStats = {notes: _('Below dust threshold'), size: 0, fee: 0};
          } else {
              $scope.txStats = undefined;
          }
          return;
      }

      var fee = CurrencyFormat.asSatoshis(sendForm.fee);
      try {
          var txUtxo = identity.wallet.getUtxoToPay(totalAmount+fee, sendForm.pocketIndex);
      } catch(e) {
          $scope.txStats = {notes: _('Not enough funds'), size: 0, fee: 0};
          return;
      }
      if (txUtxo && txUtxo.length) {
          var ins = txUtxo.length;
          if (typeof sendForm.pocketIndex === 'string') {
              // this value should come from m and n: m*73 + n*34
              var in_f = 389;
          } else {
              // standard compressed input size
              var in_f = 148;
          }
          var txSize = (outs*34+ins*in_f);
          var feePerKb = Bitcoin.networks[identity.wallet.network].feePerKb;

          // priority calculation
          // see https://en.bitcoin.it/wiki/Transaction_fees
          var minPriority = 57600000;
          var currentHeight = DarkWallet.service.wallet.currentHeight;
          var priority = 0;
          txUtxo.forEach(function(utxo) {
              priority += utxo.value*(currentHeight-utxo.height)
          });
          priority = (priority/txSize)/minPriority;

          // evaluate if we qualify for a free tx
          var txFee = (priority > 1 && txSize < 1000) ? 0.0 : Math.ceil(txSize/1000)*feePerKb;

          // now set on scope
          $scope.txStats = {size: (txSize/1000).toFixed(2), fee: txFee, priority: priority};

          return true;
      } else {
          $scope.txStats = {notes: _('Not enough good inputs'), size: 0, fee: 0};
      }
  };


  $scope.sendBitcoins = function() {

      // Prepare recipients
      var spend = prepareRecipients();
      $scope.spendBitcoins(spend);
  }

  $scope.spendBitcoins = function(spend) {
      var hasPaymentCodes = false;
      var recipients = spend.recipients;
      var contacts = spend.contacts;
      var totalAmount = spend.amount;

      var title = sendForm.title;
      if (sendForm.sending) {
          console.log("already sending");
          return;
      }

      if (!recipients.length) {
          notify.note(_('You need to fill in at least one recipient'));
          return;
      }

      var identity = DarkWallet.getIdentity();

      for(var i=0; i<recipients.length; i++) {
          if (recipients[i].amount < identity.wallet.dust) {
              notify.note(_('The amount for some recipients is below the dust threshold'), totalAmount+'<'+identity.wallet.dust);
              return;
          }
          if (BtcUtils.isPaymentCode(recipients[i].address)) {
             // We need a contact to extract pairing information
             if (!contacts[i].contact || !contacts[i].contact.mainKey) {
                 notify.note(_('Payment codes need to have a contact add a contact and pair the address first'));
                 sendForm.sending = false;
                 return;
             }

             hasPaymentCodes = true;
          }
      }
      if (totalAmount < identity.wallet.dust) {
          notify.note(_('Amount is below the dust threshold'), totalAmount+'<'+identity.wallet.dust);
          return;
      }

      if (hasPaymentCodes) {
          var needsExtension = PCodeUtils.replace(spend);
          if (!needsExtension.full) {
              // Extend payment codes here since some could not get next address.
              // TODO this shouldn't happen but atm can't completely avoid the situation so it's safer
              // to do this... the issue is some addresses will be reserved but not used if sending
              // to several payment codes
              // For most cases the payment codes will be updated after send and this doesn't happen.
              modals.password(_("Please input password to unlock payment codes"), function(password) {
                  PCodeUtils.extendSend(password, needsExtension);
              });
              notify.note(_("Some payment codes need extension. Please try again."));
              return;
          }
      }

      sendForm.sending = true;
      sendForm.propagated = false;
      var fee = CurrencyFormat.asSatoshis(sendForm.fee);

      // prepare the transaction
      var metadata;

      var pocketIndex = sendForm.pocketIndex;

      // on quick send the local pocket index overrides selected index, also there is no title
      if ($scope.quicksend && $scope.quicksend.next && $scope.quicksend.address) {
          title = false;
          if ($history.pocket.isAll) {
              pocketIndex = 0;
          } else {
              pocketIndex = $history.pocket.index;
          }
      }

      var pocketType = (typeof pocketIndex === 'string') ? 'multisig' : 'hd';
      // get a free change address
      var changeAddress = $wallet.getChangeAddress(pocketIndex, pocketType);

      try {
          metadata = identity.tx.prepare(pocketIndex,
                                         recipients,
                                         changeAddress,
                                         fee);
      } catch (error) {
          var errorMessage = error.message || ''+error;
          notify.error(_('Failed preparing transaction'), _(errorMessage));
          sendForm.sending = false;
          return;
      }

      // Add newly created addresses to the wallet
      metadata.created.forEach(function(newAddress) {
          $wallet.initAddress(newAddress);
      });

      var amountNote = (fee + totalAmount) + ' satoshis';

      // Store the label for the tx
      metadata.label = title;

      var pocket = identity.wallet.pockets.getPocket(pocketIndex, pocketType);

      // Now ask for the password before continuing with the next step   
      modals.confirmSend(_('Write your password'), {pocket: pocket, metadata: metadata}, spend.contacts, function(password) {
          // Run the password callback
          onPassword(metadata, amountNote, password, pocketType);
          // extend payment codes that are close to the limit
          if (needsExtension.contacts.length) {
              PCodeUtils.extendSend(password, needsExtension);
          }
      }, function() {
          sendForm.sending = false;
      });
  };

  $scope.removeAddress = function(field) {
      sendForm.recipients.fields.splice(sendForm.recipients.fields.indexOf(field), 1);
      if (!sendForm.recipients.fields.length) {
          $scope.addField();
      }
  };

  $scope.addAddress = function(data, vars) {
      vars.field.address = data;
      $scope.validateSendForm();
  };
  
  $scope.onQrModalOkSend = function(data, vars) {
      $scope.onQrModalOk(data, vars);
      $scope.validateSendForm();
  };

  $scope.addField = function() {
      // add the new option to the model
      sendForm.recipients.fields.push(sendForm.recipients.field_proto);
      // clear the option.
      sendForm.recipients.field_proto = { address: '', amount: '' };
  };
  
  $scope.autoAddField = function() {
      if (!sendForm.autoAddEnabled) {
          return;
      }
      var fields = sendForm.recipients.fields;
      var lastFields = fields[fields.length - 1];
      var field_keys = Object.keys(sendForm.recipients.field_proto);
      var empty;
      field_keys.forEach(function(key) {
          empty = empty || lastFields[key];
      });
      if (empty) {
          $scope.addField();
      }
  };
  
  $scope.toggleCoinJoin = function() {
      sendForm.mixing = !sendForm.mixing;
  };

  $scope.enableAutoAddFields = function() {
      $scope.addField();
      sendForm.autoAddEnabled = true;
  };

  var initialized, validAddresses;
  var initIdentity = function(identity) {
      if (!identity || initialized == identity.name) {
          return;
      }

      initialized = identity.name;
      validAddresses = [
          identity.wallet.versions.address,
          identity.wallet.versions.pcode.address,
          identity.wallet.versions.stealth.address,
          identity.wallet.versions.p2sh
      ]
 
      // Initialize the store and send form if it's the first time
      if (!sendForm || (sendForm.identity != identity.name)) {
          $scope.forms.send = { mixing: true,
                        sending: false,
                        sendPocket: 0,
                        autoAddEnabled: false,
                        identity: identity.name,
                        advanced: false };
          // Need to set the form here
          sendForm = $scope.forms.send;
          $scope.resetSendForm();
          if (location.hash.split('?')[1]) {
              parseUri(location.hash.split('?')[1].split('=')[1]);
          }
      } else {
          $scope.validateSendForm();
      }

      // init scope variables
      $scope.setPocket(sendForm.pocketIndex||0);
      if (!$scope.$$phase) {
          $scope.$apply();
      }
  };

  initIdentity(DarkWallet.getIdentity());
 
}]);
});