darkwallet/darkwallet

View on GitHub
src/js/backend/services/mixer.js

Summary

Maintainability
D
1 day
Test Coverage
'use strict';

define(['backend/port', 'util/protocol', 'bitcoinjs-lib', 'util/coinjoin', 'sjcl', 'util/stealth'],
function(Port, Protocol, Bitcoin, CoinJoin, sjcl, Stealth) {

  /*
   * Service managing mixing.
   * @constructor
   */
  function MixerService(core) {
    var self = this;
    this.name = 'mixer';
    this.core = core;
    this.ongoing = {};

    // Port for communication with other services
    Port.connect('obelisk', function(data) {
      // WakeUp when connected to obelisk
      if (data.type === 'disconnect' || data.type === 'disconnected') {
        self.stopTasks();
      }
      else if (data.type === 'connected') {
        self.checkMixing();
        // resume tasks
        self.resumeTasks();
      }

    });
  }

  /*
   * React to a new obelisk connection
   */
  MixerService.prototype.checkMixing = function() {
    var identity = this.core.getCurrentIdentity();
    var safe = this.core.service.safe;

    // Check to see we have anything to mix
    var anyMixing = false;
    identity.wallet.pockets.hdPockets.forEach(function(pocket, i) {
      if (pocket && pocket.mixing) {
        if (pocket.privKey || pocket.oldPrivKey) {
            // do we still have access to the password?
            var password = safe.get('mixer', 'pocket:'+i);
            if (!password) {
                console.log("[mixer] Disabling pocket because security context expired");
                pocket.privKey = undefined;
                pocket.oldPrivKey = undefined;
                pocket.oldPrivChangeKey = undefined;
                pocket.mixing = false;
            }
        }
        anyMixing = true;
      }
    });

    // If we have active tasks should also connect
    anyMixing = anyMixing || this.pendingTasks();

    // If any is mixing make sure we are connected
    if (anyMixing) {
      this.ensureMixing();
    } else {
      this.stopMixing();
    }
  };

  /*
   * Initialize the mixer connection
   */
  MixerService.prototype.ensureMixing = function() {
    var self = this;
    console.log("[mixer] Check mixing...");
    var lobbyTransport = this.core.getLobbyTransport();
    if (!this.channel) {
      var network = this.core.getIdentity().wallet.network;
      if (network === 'bitcoin') {
          this.channel = lobbyTransport.initChannel('CoinJoin');
      } else {
          this.channel = lobbyTransport.initChannel('CoinJoin:'+network);
      }
      this.channel.addCallback('CoinJoinOpen', function(_d) {self.onCoinJoinOpen(_d);});
      this.channel.addCallback('CoinJoin', function(_d) {self.onCoinJoin(_d, true);});
      this.channel.addCallback('CoinJoinFinish', function(_d) {self.onCoinJoinFinish(_d);});
    }
  };

  /*
   * Stop mixing
   */
  MixerService.prototype.stopMixing = function() {
    console.log("[mixer] Stop mixing...");
    if (this.channel) {
      var lobbyTransport = this.core.getLobbyTransport();
      try {
          lobbyTransport.closeChannel(this.channel.name);
      } catch(e) {
          // doesnt exist any more
      }
      this.channel = null;
    }
  };

  // Tasks

  MixerService.prototype.stopTasks = function() {
    this.ongoing = {};
  };

  /*
   * Check address - outputs pairs for funding
   * TODO: does not detect if output does not exist!
   */
  MixerService.prototype.checkOutputs = function(addresses, callback, msg) {
      var client = this.core.getClient();
      var pending = addresses.length;
      var anySpent = false;
      // Check all address - outputs pairs for funds
      Object.keys(addresses).forEach(function(address) {
          var indexes = addresses[address];
          client.fetch_history(address, 0, function(err, history) {
              if (!err) {
                  history.forEach(function(row) {
                      if (row[4] && indexes.indexOf(row[0]+":"+row[1]) > -1) {
                           console.log("[mixer]", "output", row[1], "spent");
                           anySpent = true;
                      }
                  });
              }
              pending -= 1;
              if (!pending) {
                 console.log("[mixer] tx ", anySpent?"unfunded":"funded");
                 callback(!anySpent, msg);
              }
          });
      });
  };

  /*
   * Check if all transaction inputs are funded
   */
  MixerService.prototype.isTransactionFunded = function(txHex, callback, msg) {
      var identity = this.core.getCurrentIdentity();
      var self  = this;
      var client = this.core.getClient();
      var addresses = {};
      var tx = Bitcoin.Transaction.fromHex(txHex);
      var pending = tx.ins.length;
      tx.ins.forEach(function(anIn) {
           var index = Bitcoin.bufferutils.reverse(anIn.hash).toString('hex')+":"+anIn.index;
           console.log("[mixer] check tx", index);
           if (identity.wallet.wallet.outputs[index]) {
               // this is our own input
               pending -= 1;
               return;
           }
           client.fetch_transaction(Bitcoin.bufferutils.reverse(anIn.hash).toString('hex'), function(err, txBody) {
               var outTx = Bitcoin.Transaction.fromHex(txBody);
               var address = Bitcoin.Address.fromOutputScript(outTx.outs[anIn.index].script, Bitcoin.networks[identity.wallet.network]).toString();
               if (!addresses.hasOwnProperty(address)) {
                   addresses[address] = [];
               }
               if (addresses[address].indexOf(index) === -1) {
                   addresses[address].push(index);
               }
               addresses[address].push(index);
               pending -= 1;
               if (!pending) {
                   self.checkOutputs(addresses, callback, msg);
               }
           });
      });
  };

  /*
   * Choose one of several pairing messages
   */
  MixerService.prototype.choosePeerMessage = function(coinJoin, callback) {
      var self = this;
      var pending = coinJoin.received.length;
      var funded = [];
      console.log("[mixer] choosePeer from", coinJoin.received.length, "messages");
      coinJoin.received.forEach(function(msg) {
          var onTxFunded = function(isFunded, _msg) {
              pending -= 1;
              if (isFunded) {
                  funded.push(_msg);
              }
              if (!pending) {
                  console.log("[mixer]", funded.length, "tx after fund checking");
                  callback(funded[Math.floor(Math.random()*funded.length)]);
              }
          };
          self.isTransactionFunded(msg.body.tx, onTxFunded, msg);
      });
      coinJoin.received = [];
  };

  /*
   * Check a running task to see if we have to resend or cancel
   */
  MixerService.prototype.checkAnnounce = function(msg) {
      var self = this;
      var coinJoin = this.ongoing[msg.body.id];
      if (coinJoin) {
          var start = coinJoin.task.start;
          var timeout = coinJoin.task.timeout;
          var hardMixing = this.core.getCurrentIdentity().settings.hardMixing;
          if (coinJoin.state !== 'finished' && ((Date.now()/1000)-start > timeout) && !hardMixing) {
              // Cancel task if it expired and not finished
              console.log("[mixer] Cancelling coinjoin!", msg.body.id);
              Port.post('gui', {type: 'mixer', state: 'Sending with no mixing'});
              var walletService = this.core.service.wallet;
              walletService.sendFallback('mixer', coinJoin.task);
          } else if (coinJoin.state === 'announce') {
              if (coinJoin.received.length) {
                  this.choosePeerMessage(coinJoin, function(_msg) {
                      if (_msg) {
                          setTimeout(function() {
                              self.checkAnnounce(msg);
                          }, 10000);
                          self.onCoinJoin(_msg);
                      } else {
                          // Otherwise resend
                          self.postRetry(msg);
                          Port.post('gui', {type: 'mixer', state: 'Announcing'});
                      }
                  });
              } else {
                  // Otherwise resend
                  this.postRetry(msg);
                  Port.post('gui', {type: 'mixer', state: 'Announcing'});
              }
          } else if (coinJoin.state !== 'finished' && ((Date.now()/1000)-coinJoin.task.ping > (timeout/10))) {
              coinJoin.cancel();
              // Otherwise resend
              this.postRetry(msg);
              Port.post('gui', {type: 'mixer', state: 'Announcing'});
          }
      }
  };

  /*
   * Send a message on the channel and schedule a retry
   */
  MixerService.prototype.postRetry = function(msg) {
      var self = this;
      this.channel.postEncrypted(msg, function(err) {
          if (err) {
            console.log("[mixer] Error announcing join!");
          } else {
            console.log("[mixer] Join announced!");
          }
      });
      setTimeout(function() {
            self.checkAnnounce(msg);
      }, 10000);
  };

  /**
   * Announce a coinjoin.
   */
  MixerService.prototype.announce = function(id, coinJoin) {
      var msg = Protocol.CoinJoinOpenMsg(id, coinJoin.myAmount);
      this.checkAnnounce(msg);
  };

  /*
   * Start a task either internally or by external command
   */
  MixerService.prototype.startTask = function(task) {
    // Make sure the mixer is enabled
    this.ensureMixing();

    // Now do stuff with the task...
    switch(task.state) {
      case 'announce':
        var id = Bitcoin.crypto.sha256(Math.random()+'').toString('hex');
        console.log("[mixer] Announce join");
        var myTx = Bitcoin.Transaction.fromHex(task.tx);
        if (!task.timeout) {
           task.timeout = 60;
        }
        if (!task.start) {
           task.start = Date.now()/1000;
           task.ping = task.start;
        }
        var amount = (task.change && (Math.random() < 0.5)) ? task.change : task.total;
        this.ongoing[id] = new CoinJoin(this.core, 'initiator', 'announce', myTx, amount, task.fee);
        this.ongoing[id].received = [];
        this.ongoing[id].task = task;

        // See if the task is expired otherwise send
        this.announce(id, this.ongoing[id]);
        break;
      case 'paired':
      case 'finish':
      case 'finished':
      default:
        console.log('[mixer] start Task!', task.state, task);
        break;
    }
  };

  /*
   * Count the number of pending tasks
   */
  MixerService.prototype.pendingTasks = function() {
    var identity = this.core.getCurrentIdentity();
    console.log('[mixer] check Tasks!');

    return identity.tasks.getTasks('mixer').length;
  };

  /*
   * Resume available (pending) tasks
   */
  MixerService.prototype.resumeTasks = function() {
    var self = this;
    var identity = this.core.getCurrentIdentity();

    var tasks = identity.tasks.getTasks('mixer');
    tasks.forEach(function(task) {
      self.startTask(task);
    });
  };

  /*
   * Find a pocket in mixing state with enough satoshis
   */
  MixerService.prototype.findMixingPocket = function(amount) {
    var identity = this.core.getCurrentIdentity();
    var pockets = identity.wallet.pockets.hdPockets;
    for(var i=0; i<pockets.length; i++) {
      var pocket = pockets[i];
      if (pocket && pocket.mixing) {
        var balance = identity.wallet.getBalance(i, 'hd').confirmed;
        if (balance >= amount) {
            return i;
        }
      }
    }
    return -1;
  };

  MixerService.prototype.getMixingLevel = function(totalAmount, pocketIndex) {
    var identity = this.core.getCurrentIdentity();
      var utxo = identity.wallet.getUtxoToPay(totalAmount, pocketIndex);
      var mixingLevel = 5; // target
      utxo.forEach(function(output) {
          var walletAddress = identity.wallet.getWalletAddress(output.address);
          if (walletAddress.type === 'hd') {
              mixingLevel = Math.min(mixingLevel, walletAddress.index[1]);
          } else if (walletAddress.type === undefined) {
              mixingLevel = Math.min(mixingLevel, walletAddress.index[0]%2);
          } else {
              // other addresses get 0 (we treat them like a public address)
              mixingLevel = 0;
          }
          
      });
      return mixingLevel;
  };

  /*
   * Evaluate a coinjoin opening and respond if appropriate.
   */
  MixerService.prototype.evaluateOpening = function(peer, opening) {

    if (this.ongoing.hasOwnProperty(opening.id)) {
        console.log("[mixer] We already have this task!");
        return;
    }
    var fee = 10000; // 0.1 mBTC
    var identity = this.core.getCurrentIdentity();

    // Evaluate mixing pockets to see if we can pair

    var pocketIndex = this.findMixingPocket(opening.amount+fee);

    // If we found a pocket, continue with the protocol.
    if (pocketIndex !== -1) {
      var pocket = identity.wallet.pockets.getPocket(pocketIndex, 'hd');
      var mixingLevel = this.getMixingLevel(opening.amount+fee, pocketIndex);

      // Build the tx
      var changeAddress = pocket.getChangeAddress('mixing');
      var destAddress = pocket.getFreeAddress(Math.max(mixingLevel+1, 2), 'mixing');
      var recipient = {address: destAddress.address, amount: opening.amount};

      console.log("[mixer] mixing to level", mixingLevel);
      var metadata = identity.tx.prepare(pocketIndex, [recipient], changeAddress, fee);

      var guestTx = metadata.tx.clone();
      this.ongoing[opening.id] = new CoinJoin(this.core, 'guest', 'accepted', guestTx, opening.amount, fee, peer);
      this.ongoing[opening.id].pocket = pocketIndex;

      // Post using end to end channel capabilities
      this.sendTo(peer, opening.id, metadata.tx);
    }
  };

  MixerService.prototype.sendTo = function(peer, id, tx, callback) {
      // Now create and send the message
      var msg = Protocol.CoinJoinMsg(id, tx.toHex());
      this.channel.postDH(peer.pubKey, msg, function(err, data) {
          callback ? callback(err, data) : null;
      });
  };

  /*
   * Check join state to see if we need to delete, and do it
   */
  MixerService.prototype.checkFinished = function(id, coinJoin) {
      // Check state and perform appropriate tasks
      if (coinJoin.state === 'finished' && coinJoin.task) {
          var onBroadcast = function(error, data) {
              console.log("broadcasting!", error, data);
          };
          var walletService = this.core.service.wallet;
          coinJoin.task.tx = coinJoin.tx.toHex();
          walletService.broadcastTx(coinJoin.tx, coinJoin.task, onBroadcast);
      }
      // Now remove if we're on an end state
      if (['finished', 'cancelled'].indexOf(coinJoin.state) !== -1) {
          console.log("[mixer] Deleting coinjoin because " + coinJoin.state);
          delete this.ongoing[id];
      }
  };

  /*
   * Get a running coinjoin from a message doing some tests
   */
  MixerService.prototype.getOngoing = function(msg) {
      var coinJoin = this.ongoing[msg.body.id];
      if (!coinJoin) {
          console.log("[mixer] CoinJoin not found!");
      }
      return coinJoin;
  };

  /**
   * Get the host private keys for a mix
   */
  MixerService.prototype.hostPrivateKeys = function(coinJoin) {
      var safe = this.core.service.safe;
      var txHash = Bitcoin.convert.bytesToString(coinJoin.myTx.getHash());

      var password = safe.get('send', txHash);

      return JSON.parse(sjcl.decrypt(password, coinJoin.task.privKeys));
  };

  /**
   * Get the guest private keys for a mix
   */
  MixerService.prototype.guestPrivateKeys = function(coinJoin) {
      var identity = this.core.getIdentity();
      var safe = this.core.service.safe;
      var pocketIndex = coinJoin.pocket;

      // Get our password from the safe
      var password = safe.get('mixer', 'pocket:'+pocketIndex);

      // Load master keys for the pockets
      var pocket = identity.wallet.pockets.getPocket(pocketIndex, 'hd');
      var masterKey, oldMasterKey, oldChangeKey;
      if (pocket.store.privKey) {
          masterKey = Bitcoin.HDNode.fromBase58(sjcl.decrypt(password, pocket.store.privKey));
      }
      if (pocket.store.oldPrivKey) {
          oldMasterKey = Bitcoin.HDNode.fromBase58(sjcl.decrypt(password, pocket.store.oldPrivKey));
          oldChangeKey = Bitcoin.HDNode.fromBase58(sjcl.decrypt(password, pocket.store.oldPrivChangeKey));
      }

      // Iterate over tx inputs and load private keys
      var privKeys = {};
      for(var i=0; i<coinJoin.myTx.ins.length; i++) {
          var anIn = coinJoin.myTx.ins[i];
          var output = identity.wallet.wallet.outputs[Bitcoin.bufferutils.reverse(anIn.hash).toString('hex')+":"+anIn.index];
          // we're only adding keyhash inputs for now
          if (!output) {
              throw new Error('Invalid input in our join (no output)');
          }
          var walletAddress = identity.wallet.getWalletAddress(output.address);

          // only normal addresses supported for now
          if (!walletAddress || ['stealth', 'hd', 'pocket', undefined].indexOf(walletAddress.type) === -1) {
              throw new Error('Invalid input in our join (bad address)');
          }
          // skip if we already got this key
          if (privKeys[walletAddress.index]) {
              continue;
          }

          var isNewStealth = (walletAddress.type === 'stealth' && identity.store.get('version') > 4);
          var isNewHd = (walletAddress.type === 'hd');
          var walletPocketIdx = (isNewHd || isNewStealth) ? walletAddress.index[0] : Math.floor(walletAddress.index[0]/2);
          if (walletPocketIdx !== pocketIndex) {
              throw new Error('Address from an invalid pocket');
          }
          // derive this key
          var change = isNewHd ? walletAddress.index[1] : (isNewStealth ? false : walletAddress.index[0]%2);
          var seq = walletAddress.index.slice(0);
          if (isNewStealth) {
              var scanKey = identity.wallet.getScanKey(seq[0]);
              privKeys[seq] = Stealth.uncoverPrivate(scanKey.toBytes(), seq.slice(2), masterKey.privKey.toBytes()).toBytes();
          } else if (walletAddress.type === 'oldstealth' || (walletAddress.type === 'stealth' && identity.store.get('version') < 5)) {
              var pocketMaster = change?oldChangeKey:oldMasterKey;
              // second parameter for getScanKey is so scankey will be different
              var scanKey = identity.wallet.getScanKey(seq[0], true);
              privKeys[seq] = Stealth.uncoverPrivate(scanKey.toBytes(), seq.slice(2), pocketMaster.privKey.toBytes()).toBytes();
          } else if (walletAddress.type === 'hd' || walletAddress.type === 'pocket') {
              privKeys[seq] = pocket.deriveHDPrivateKey(seq.slice(1), masterKey).toBytes();
          } else {
              privKeys[seq] = pocket.deriveHDPrivateKey(seq.slice(1), change?oldChangeKey:oldMasterKey).toBytes();
          }
      }
      return privKeys;
  };

  /*
   * Sign inputs for a coinjoin
   */
  MixerService.prototype.requestSignInputs = function(coinJoin) {
      var privKeys;
      var identity = this.core.getIdentity();
      if (coinJoin.task) {
          privKeys = this.hostPrivateKeys(coinJoin);
      } else {
          privKeys = this.guestPrivateKeys(coinJoin);
          
      }
      var signed = identity.tx.signMyInputs(coinJoin.myTx.ins, coinJoin.tx, privKeys);
      return signed;
  };

  /**
   * CoinJoin open arrived
   */
  MixerService.prototype.onCoinJoinOpen = function(msg) {
    if (!msg.peer || !msg.peer.trusted) {
      console.log("[mixer] Peer not found " + msg.sender, msg.peer);
      return;
    }
    if (msg.sender !== this.channel.fingerprint) {
      console.log("[mixer] CoinJoinOpen ", msg.body.id, msg.sender);
      this.evaluateOpening(msg.peer, msg.body);
    } else {
      console.log("[mixer] My CoinJoinOpen is back");
    }
  };

  /**
   * CoinJoin arrived
   */
  MixerService.prototype.onCoinJoin = function(msg, initial) {
    if (msg.sender !== this.channel.fingerprint) {
      var coinJoin = this.getOngoing(msg);
      if (coinJoin) {
          if (initial && coinJoin.state === 'announce') {
              coinJoin.received.push(msg);
              return;
          }
          var prevState = coinJoin.state;
          console.log("[mixer] CoinJoin", msg);

          var updatedTx = coinJoin.process(msg.body, msg.peer);
          // if requested to sign, try to do it
          if (coinJoin.state === 'sign') {
              // Needs signing from user
              var signed = this.requestSignInputs(coinJoin);
              if (signed) {
                  updatedTx = coinJoin.addSignatures(coinJoin.tx);
              }
          }
          if (updatedTx && coinJoin.state !== 'sign') {
              this.sendTo(msg.peer, msg.body.id, updatedTx);
          }
          if (updatedTx) {
              Port.post('gui', {type: 'mixer', state: coinJoin.state});
          }
          // copy coinjoin state to the store
          if (coinJoin.task) {
              coinJoin.task.ping = Date.now()/1000;
              coinJoin.task.state = coinJoin.state;
          }
          // Update budget (only guest applies budgeting)
          if (coinJoin.state === 'finished' && prevState !== 'finished' && coinJoin.role === 'guest') {
              this.trackBudget(coinJoin);
          }
          // Check for deletion
          this.checkFinished(msg.body.id, coinJoin);

          // See if we should desactivate mixing
          this.checkMixing();
      }
    }
  };

  /**
   * CoinJoinFinish arrived
   */
  MixerService.prototype.onCoinJoinFinish = function(msg) {
    if (msg.sender !== this.channel.fingerprint) {
      console.log("[mixer] CoinJoinFinish", msg);
      var coinJoin = this.getOngoing(msg);
      if (coinJoin) {
        coinJoin.kill(msg.body, msg.peer);
        this.checkFinished(msg.body.id, coinJoin);
      }
    }
  };

  /**
   * Budgeting
   */
  MixerService.prototype.trackBudget = function(coinJoin) {
    var identity = this.core.getCurrentIdentity();
    var pocketStore = identity.wallet.pockets.getPocket(coinJoin.pocket, 'hd').store;
    pocketStore.mixingOptions.spent += coinJoin.fee;
    if (pocketStore.mixingOptions.spent >= pocketStore.mixingOptions.budget) {
      pocketStore.privKey = undefined;
      pocketStore.privChangeKey = undefined;
      pocketStore.mixing = false;
    }
    identity.wallet.store.save();
  };

  return MixerService;

});