Source: wallet/account.js

/*!
 * account.js - account object for hsd
 * Copyright (c) 2017-2018, Christopher Jeffrey (MIT License).
 * https://github.com/handshake-org/hsd
 */

'use strict';

const assert = require('bsert');
const bio = require('bufio');
const binary = require('../utils/binary');
const Path = require('./path');
const common = require('./common');
const Script = require('../script/script');
const WalletKey = require('./walletkey');
const {HDPublicKey} = require('../hd/hd');

/**
 * Account
 * Represents a BIP44 Account belonging to a {@link Wallet}.
 * Note that this object does not enforce locks. Any method
 * that does a write is internal API only and will lead
 * to race conditions if used elsewhere.
 * @alias module:wallet.Account
 */

class Account extends bio.Struct {
  /**
   * Create an account.
   * @constructor
   * @param {Object} options
   */

  constructor(wdb, options) {
    super();

    assert(wdb, 'Database is required.');

    this.wdb = wdb;
    this.network = wdb.network;

    this.wid = 0;
    this.id = null;
    this.accountIndex = 0;
    this.name = null;
    this.initialized = false;
    this.watchOnly = false;
    this.type = Account.types.PUBKEYHASH;
    this.m = 1;
    this.n = 1;
    this.receiveDepth = 0;
    this.changeDepth = 0;
    this.lookahead = 200;
    this.accountKey = null;
    this.keys = [];

    if (options)
      this.fromOptions(options);
  }

  /**
   * Inject properties from options object.
   * @private
   * @param {Object} options
   */

  fromOptions(options) {
    assert(options, 'Options are required.');
    assert((options.wid >>> 0) === options.wid);
    assert(common.isName(options.id), 'Bad Wallet ID.');
    assert(HDPublicKey.isHDPublicKey(options.accountKey),
      'Account key is required.');
    assert((options.accountIndex >>> 0) === options.accountIndex,
      'Account index is required.');

    this.wid = options.wid;
    this.id = options.id;

    if (options.accountIndex != null) {
      assert((options.accountIndex >>> 0) === options.accountIndex);
      this.accountIndex = options.accountIndex;
    }

    if (options.name != null) {
      assert(common.isName(options.name), 'Bad account name.');
      this.name = options.name;
    }

    if (options.initialized != null) {
      assert(typeof options.initialized === 'boolean');
      this.initialized = options.initialized;
    }

    if (options.watchOnly != null) {
      assert(typeof options.watchOnly === 'boolean');
      this.watchOnly = options.watchOnly;
    }

    if (options.type != null) {
      if (typeof options.type === 'string') {
        this.type = Account.types[options.type.toUpperCase()];
        assert(this.type != null);
      } else {
        assert(typeof options.type === 'number');
        this.type = options.type;
        assert(Account.typesByVal[this.type]);
      }
    }

    if (options.m != null) {
      assert((options.m & 0xff) === options.m);
      this.m = options.m;
    }

    if (options.n != null) {
      assert((options.n & 0xff) === options.n);
      this.n = options.n;
    }

    if (options.receiveDepth != null) {
      assert((options.receiveDepth >>> 0) === options.receiveDepth);
      this.receiveDepth = options.receiveDepth;
    }

    if (options.changeDepth != null) {
      assert((options.changeDepth >>> 0) === options.changeDepth);
      this.changeDepth = options.changeDepth;
    }

    if (options.lookahead != null) {
      assert((options.lookahead >>> 0) === options.lookahead);
      assert(options.lookahead >= 0);
      this.lookahead = options.lookahead;
    }

    this.accountKey = options.accountKey;

    if (this.n > 1)
      this.type = Account.types.MULTISIG;

    if (!this.name)
      this.name = this.accountIndex.toString(10);

    if (this.m < 1 || this.m > this.n)
      throw new Error('m ranges between 1 and n');

    if (options.keys) {
      assert(Array.isArray(options.keys));
      for (const key of options.keys)
        this.pushKey(key);
    }

    return this;
  }

  /**
   * Inject properties from options object.
   * @private
   * @param {Object} options
   */

  static fromOptions(wdb, options) {
    return new this(wdb).fromOptions(options);
  }

  /**
   * Attempt to intialize the account (generating
   * the first addresses along with the lookahead
   * addresses). Called automatically from the
   * walletdb.
   * @returns {Promise}
   */

  async init(b) {
    // Waiting for more keys.
    if (this.keys.length !== this.n - 1) {
      assert(!this.initialized);
      this.save(b);
      return;
    }

    assert(this.receiveDepth === 0);
    assert(this.changeDepth === 0);

    this.initialized = true;

    await this.initDepth(b);
  }

  /**
   * Add a public account key to the account (multisig).
   * Does not update the database.
   * @param {HDPublicKey} key - Account (bip44)
   * key (can be in base58 form).
   * @throws Error on non-hdkey/non-accountkey.
   */

  pushKey(key) {
    if (typeof key === 'string')
      key = HDPublicKey.fromBase58(key, this.network);

    if (!HDPublicKey.isHDPublicKey(key))
      throw new Error('Must add HD keys to wallet.');

    if (!key.isAccount())
      throw new Error('Must add HD account keys to BIP44 wallet.');

    if (this.type !== Account.types.MULTISIG)
      throw new Error('Cannot add keys to non-multisig wallet.');

    if (key.equals(this.accountKey))
      throw new Error('Cannot add own key.');

    const index = binary.insert(this.keys, key, cmp, true);

    if (index === -1)
      return false;

    if (this.keys.length > this.n - 1) {
      binary.remove(this.keys, key, cmp);
      throw new Error('Cannot add more keys.');
    }

    return true;
  }

  /**
   * Remove a public account key to the account (multisig).
   * Does not update the database.
   * @param {HDPublicKey} key - Account (bip44)
   * key (can be in base58 form).
   * @throws Error on non-hdkey/non-accountkey.
   */

  spliceKey(key) {
    if (typeof key === 'string')
      key = HDPublicKey.fromBase58(key, this.network);

    if (!HDPublicKey.isHDPublicKey(key))
      throw new Error('Must add HD keys to wallet.');

    if (!key.isAccount())
      throw new Error('Must add HD account keys to BIP44 wallet.');

    if (this.type !== Account.types.MULTISIG)
      throw new Error('Cannot remove keys from non-multisig wallet.');

    if (this.keys.length === this.n - 1)
      throw new Error('Cannot remove key.');

    return binary.remove(this.keys, key, cmp);
  }

  /**
   * Add a public account key to the account (multisig).
   * Saves the key in the wallet database.
   * @param {HDPublicKey} key
   * @returns {Promise}
   */

  async addSharedKey(b, key) {
    const result = this.pushKey(key);

    if (await this.hasDuplicate()) {
      this.spliceKey(key);
      throw new Error('Cannot add a key from another account.');
    }

    // Try to initialize again.
    await this.init(b);

    return result;
  }

  /**
   * Ensure accounts are not sharing keys.
   * @private
   * @returns {Promise}
   */

  async hasDuplicate() {
    if (this.keys.length !== this.n - 1)
      return false;

    const ring = this.deriveReceive(0);
    const hash = ring.getScriptHash();

    return this.wdb.hasPath(this.wid, hash);
  }

  /**
   * Remove a public account key from the account (multisig).
   * Remove the key from the wallet database.
   * @param {HDPublicKey} key
   * @returns {Promise}
   */

  removeSharedKey(b, key) {
    const result = this.spliceKey(key);

    if (!result)
      return false;

    this.save(b);

    return true;
  }

  /**
   * Create a new receiving address (increments receiveDepth).
   * @returns {WalletKey}
   */

  createReceive() {
    return this.createKey(0);
  }

  /**
   * Create a new change address (increments changeDepth).
   * @returns {WalletKey}
   */

  createChange() {
    return this.createKey(1);
  }

  /**
   * Create a new address (increments depth).
   * @param {Boolean} change
   * @returns {Promise} - Returns {@link WalletKey}.
   */

  async createKey(b, branch) {
    let key, lookahead;

    switch (branch) {
      case 0:
        key = this.deriveReceive(this.receiveDepth);
        lookahead = this.deriveReceive(this.receiveDepth + this.lookahead);
        await this.saveKey(b, lookahead);
        this.receiveDepth += 1;
        this.receive = key;
        break;
      case 1:
        key = this.deriveChange(this.changeDepth);
        lookahead = this.deriveChange(this.changeDepth + this.lookahead);
        await this.saveKey(b, lookahead);
        this.changeDepth += 1;
        this.change = key;
        break;
      default:
        throw new Error(`Bad branch: ${branch}.`);
    }

    this.save(b);

    return key;
  }

  /**
   * Derive a receiving address at `index`. Do not increment depth.
   * @param {Number} index
   * @returns {WalletKey}
   */

  deriveReceive(index, master) {
    return this.deriveKey(0, index, master);
  }

  /**
   * Derive a change address at `index`. Do not increment depth.
   * @param {Number} index
   * @returns {WalletKey}
   */

  deriveChange(index, master) {
    return this.deriveKey(1, index, master);
  }

  /**
   * Derive an address from `path` object.
   * @param {Path} path
   * @param {MasterKey} master
   * @returns {WalletKey}
   */

  derivePath(path, master) {
    switch (path.keyType) {
      case Path.types.HD: {
        return this.deriveKey(path.branch, path.index, master);
      }
      case Path.types.KEY: {
        assert(this.type === Account.types.PUBKEYHASH);

        let data = path.data;

        if (path.encrypted) {
          data = master.decipher(data, path.hash);
          if (!data)
            return null;
        }

        return WalletKey.fromImport(this, data);
      }
      case Path.types.ADDRESS: {
        return null;
      }
      default: {
        throw new Error('Bad key type.');
      }
    }
  }

  /**
   * Derive an address at `index`. Do not increment depth.
   * @param {Number} branch
   * @param {Number} index
   * @returns {WalletKey}
   */

  deriveKey(branch, index, master) {
    assert(typeof branch === 'number');

    const keys = [];

    let key;
    if (master && master.key && !this.watchOnly) {
      const type = this.network.keyPrefix.coinType;
      key = master.key.deriveAccount(44, type, this.accountIndex);
      key = key.derive(branch).derive(index);
    } else {
      key = this.accountKey.derive(branch).derive(index);
    }

    const ring = WalletKey.fromHD(this, key, branch, index);

    switch (this.type) {
      case Account.types.PUBKEYHASH:
        break;
      case Account.types.MULTISIG:
        keys.push(key.publicKey);

        for (const shared of this.keys) {
          const key = shared.derive(branch).derive(index);
          keys.push(key.publicKey);
        }

        ring.script = Script.fromMultisig(this.m, this.n, keys);

        break;
    }

    return ring;
  }

  /**
   * Save the account to the database. Necessary
   * when address depth and keys change.
   * @returns {Promise}
   */

  save(b) {
    return this.wdb.saveAccount(b, this);
  }

  /**
   * Save addresses to path map.
   * @param {WalletKey[]} rings
   * @returns {Promise}
   */

  saveKey(b, ring) {
    return this.wdb.saveKey(b, this.wid, ring);
  }

  /**
   * Save paths to path map.
   * @param {Path[]} rings
   * @returns {Promise}
   */

  savePath(b, path) {
    return this.wdb.savePath(b, this.wid, path);
  }

  /**
   * Initialize address depths (including lookahead).
   * @returns {Promise}
   */

  async initDepth(b) {
    // Receive Address
    this.receiveDepth = 1;

    for (let i = 0; i <= this.lookahead; i++) {
      const key = this.deriveReceive(i);
      await this.saveKey(b, key);
    }

    // Change Address
    this.changeDepth = 1;

    for (let i = 0; i <= this.lookahead; i++) {
      const key = this.deriveChange(i);
      await this.saveKey(b, key);
    }

    this.save(b);
  }

  /**
   * Allocate new lookahead addresses if necessary.
   * @param {Number} receiveDepth
   * @param {Number} changeDepth
   * @returns {Promise} - Returns {@link WalletKey}.
   */

  async syncDepth(b, receive, change) {
    let derived = false;
    let result = null;

    if (receive > this.receiveDepth) {
      const depth = this.receiveDepth + this.lookahead;

      assert(receive <= depth + 1);

      for (let i = depth; i < receive + this.lookahead; i++) {
        const key = this.deriveReceive(i);
        await this.saveKey(b, key);
        result = key;
      }

      this.receiveDepth = receive;

      derived = true;
    }

    if (change > this.changeDepth) {
      const depth = this.changeDepth + this.lookahead;

      assert(change <= depth + 1);

      for (let i = depth; i < change + this.lookahead; i++) {
        const key = this.deriveChange(i);
        await this.saveKey(b, key);
      }

      this.changeDepth = change;

      derived = true;
    }

    if (derived)
      this.save(b);

    return result;
  }

  /**
   * Allocate new lookahead addresses.
   * @param {Number} lookahead
   * @returns {Promise}
   */

  async setLookahead(b, lookahead) {
    assert((lookahead >>> 0) === lookahead, 'Lookahead must be a number.');

    if (lookahead === this.lookahead)
      return;

    if (lookahead < this.lookahead) {
      const diff = this.lookahead - lookahead;

      this.receiveDepth += diff;
      this.changeDepth += diff;

      this.lookahead = lookahead;

      this.save(b);

      return;
    }

    {
      const depth = this.receiveDepth + this.lookahead;
      const target = this.receiveDepth + lookahead;

      for (let i = depth; i < target; i++) {
        const key = this.deriveReceive(i);
        await this.saveKey(b, key);
      }
    }

    {
      const depth = this.changeDepth + this.lookahead;
      const target = this.changeDepth + lookahead;

      for (let i = depth; i < target; i++) {
        const key = this.deriveChange(i);
        await this.saveKey(b, key);
      }
    }

    this.lookahead = lookahead;
    this.save(b);
  }

  /**
   * Get current receive key.
   * @returns {WalletKey}
   */

  receiveKey() {
    if (!this.initialized)
      return null;

    return this.deriveReceive(this.receiveDepth - 1);
  }

  /**
   * Get current change key.
   * @returns {WalletKey}
   */

  changeKey() {
    if (!this.initialized)
      return null;

    return this.deriveChange(this.changeDepth - 1);
  }

  /**
   * Get current receive address.
   * @returns {Address}
   */

  receiveAddress() {
    const key = this.receiveKey();

    if (!key)
      return null;

    return key.getAddress();
  }

  /**
   * Get current change address.
   * @returns {Address}
   */

  changeAddress() {
    const key = this.changeKey();

    if (!key)
      return null;

    return key.getAddress();
  }

  /**
   * Convert the account to a more inspection-friendly object.
   * @returns {Object}
   */

  format() {
    const receive = this.receiveAddress();
    const change = this.changeAddress();

    return {
      id: this.id,
      wid: this.wid,
      name: this.name,
      network: this.network.type,
      initialized: this.initialized,
      watchOnly: this.watchOnly,
      type: Account.typesByVal[this.type].toLowerCase(),
      m: this.m,
      n: this.n,
      accountIndex: this.accountIndex,
      receiveDepth: this.receiveDepth,
      changeDepth: this.changeDepth,
      lookahead: this.lookahead,
      receiveAddress: receive ? receive.toString(this.network) : null,
      changeAddress: change ? change.toString(this.network) : null,
      accountKey: this.accountKey.toBase58(this.network),
      keys: this.keys.map(key => key.toBase58(this.network))
    };
  }

  /**
   * Convert the account to an object suitable for
   * serialization.
   * @returns {Object}
   */

  getJSON(balance) {
    const receive = this.receiveAddress();
    const change = this.changeAddress();

    return {
      name: this.name,
      initialized: this.initialized,
      watchOnly: this.watchOnly,
      type: Account.typesByVal[this.type].toLowerCase(),
      m: this.m,
      n: this.n,
      accountIndex: this.accountIndex,
      receiveDepth: this.receiveDepth,
      changeDepth: this.changeDepth,
      lookahead: this.lookahead,
      receiveAddress: receive ? receive.toString(this.network) : null,
      changeAddress: change ? change.toString(this.network) : null,
      accountKey: this.accountKey.toBase58(this.network),
      keys: this.keys.map(key => key.toBase58(this.network)),
      balance: balance ? balance.toJSON(true) : null
    };
  }

  /**
   * Calculate serialization size.
   * @returns {Number}
   */

  getSize() {
    let size = 0;
    size += 91;
    size += this.keys.length * 74;
    return size;
  }

  /**
   * Serialize the account.
   * @returns {Buffer}
   */

  write(bw) {
    let flags = 0;

    if (this.initialized)
      flags |= 1;

    bw.writeU8(flags);
    bw.writeU8(this.type);
    bw.writeU8(this.m);
    bw.writeU8(this.n);
    bw.writeU32(this.receiveDepth);
    bw.writeU32(this.changeDepth);
    bw.writeU32(this.lookahead);
    writeKey(this.accountKey, bw);
    bw.writeU8(this.keys.length);

    for (const key of this.keys)
      writeKey(key, bw);

    return bw;
  }

  /**
   * Inject properties from serialized data.
   * @private
   * @param {Buffer} data
   * @returns {Object}
   */

  read(br) {
    const flags = br.readU8();

    this.initialized = (flags & 1) !== 0;
    this.type = br.readU8();
    this.m = br.readU8();
    this.n = br.readU8();
    this.receiveDepth = br.readU32();
    this.changeDepth = br.readU32();
    this.lookahead = br.readU32();
    this.accountKey = readKey(br);

    assert(this.type < Account.typesByVal.length);

    const count = br.readU8();

    for (let i = 0; i < count; i++) {
      const key = readKey(br);
      binary.insert(this.keys, key, cmp, true);
    }

    return this;
  }

  /**
   * Decode account.
   * @param {WalletDB} wdb
   * @param {Buffer} data
   * @returns {Account}
   */

  static decode(wdb, data) {
    return new this(wdb).decode(data);
  }

  /**
   * Test an object to see if it is a Account.
   * @param {Object} obj
   * @returns {Boolean}
   */

  static isAccount(obj) {
    return obj instanceof Account;
  }
}

/**
 * Account types.
 * @enum {Number}
 * @default
 */

Account.types = {
  PUBKEYHASH: 0,
  MULTISIG: 1
};

/**
 * Account types by value.
 * @const {Object}
 */

Account.typesByVal = [
  'PUBKEYHASH',
  'MULTISIG'
];

/*
 * Helpers
 */

function cmp(a, b) {
  return a.compare(b);
}

function writeKey(key, bw) {
  bw.writeU8(key.depth);
  bw.writeU32BE(key.parentFingerPrint);
  bw.writeU32BE(key.childIndex);
  bw.writeBytes(key.chainCode);
  bw.writeBytes(key.publicKey);
}

function readKey(br) {
  const key = new HDPublicKey();
  key.depth = br.readU8();
  key.parentFingerPrint = br.readU32BE();
  key.childIndex = br.readU32BE();
  key.chainCode = br.readBytes(32);
  key.publicKey = br.readBytes(33);
  return key;
}

/*
 * Expose
 */

module.exports = Account;