Source: services/lightning.js

'use strict';

// Dependencies
const net = require('net');

// Fabric Types
const Actor = require('../types/actor');
const Key = require('../types/key');
const Remote = require('../types/remote');
const Service = require('../types/service');
const Machine = require('../types/machine');

// Contracts
const OP_TEST = require('../contracts/test');

/**
 * Manage a Lightning node.
 */
class Lightning extends Service {
  /**
   * Create an instance of the Lightning {@link Service}.
   * @param {Object} [settings] Settings.
   * @returns {Lightning}
   */
  constructor (settings = {}) {
    super(settings);

    this.settings = Object.assign({
      authority: 'http://127.0.0.1:8181',
      host: '127.0.0.1',
      port: 8181,
      path: './stores/lightning',
      mode: 'socket',
      interval: 1000
    }, this.settings, settings);

    this.machine = new Machine(this.settings);
    this.rpc = null;
    this.rest = null;
    this.status = 'disconnected';
    this.plugin = null;

    this._state = {
      content: {
        actors: {},
        balances: {},
        channels: {},
        blockheight: null,
        node: {
          id: null,
          alias: null,
          color: null
        }
      },
      channels: {},
      invoices: {},
      peers: {},
      nodes: {}
    };

    return this;
  }

  static plugin (state) {
    const lightning = new Lightning(state);
    const plugin = new LightningPlugin(state);
    plugin.addMethod('test', OP_TEST.bind(lightning));
    // plugin.addMethod('init');
    return plugin;
  }

  get balances () {
    return this.state.balances;
  }

  commit () {
    // this.emit('debug', `Committing...`);

    const commit = new Actor({
      type: 'Commit',
      state: this.state
    });

    // this.emit('debug', `Committing Actor: ${commit}`);

    this.emit('commit', {
      id: commit.id,
      object: commit.toObject()
    });

    return commit;
  }

  restErrorHandler (error) {
    this.emit('error', `Got REST error: ${error}`);
  }

  async start () {
    this.status = 'starting';

    await this.machine.start();

    switch (this.settings.mode) {
      default:
        throw new Error(`Unknown mode: ${this.settings.mode}`);
      case 'grpc':
        throw new Error('Disabled.');
      case 'rest':
        // TODO: re-work Polar integration
        const provider = new URL(this.settings.authority);

        // Fabric Remote for target REST interface
        this.rest = new Remote({
          host: this.settings.host,
          macaroon: this.settings.macaroon,
          username: provider.username,
          password: provider.password,
          port: this.settings.port,
          secure: this.settings.secure
        });

        // Error Handler
        this.rest.on('error', this.restErrorHandler.bind(this));

        // Sync data from the target
        await this._syncOracleInfo();

        break;
      case 'rpc':
        throw new Error('Disabled.');
      case 'socket':
        this.emit('debug', 'Opening Lightning socket...');
        await this._sync();
        break;
    }

    this._heart = setInterval(this._heartbeat.bind(this), this.settings.interval);
    this.status = 'started';

    this.emit('ready', this.export());

    return this;
  }

  async listFunds () {
    return this._makeRPCRequest('listfunds');
  }

  async _heartbeat () {
    await this._syncOracleInfo();
    return this;
  }

  async _generateSmallestInvoice () {
    return await this._generateInvoice(1);
  }

  async _generateInvoice (amount, expiry = 120, description = 'nothing relevant') {
    let result = null;

    if (this.settings.mode === 'rest') {
      const key = new Key();
      const actor = new Actor({
        id: key.id,
        type: 'LightningInvoice',
        data: { amount, expiry }
      });

      const invoice = await this.rest._POST('/invoice/genInvoice', {
        label: actor.id,
        amount: amount,
        expiry: expiry,
        description: description
      });

      result = Object.assign({}, actor.state, {
        encoded: invoice.bolt11,
        expiry: invoice.expires_at,
        data: invoice
      });

      this._state.invoices[key.id] = result;
      await this.commit();
    }

    return result;
  }

  async _makeGRPCRequest (method, params = []) {
    return new Promise((resolve, reject) => {
      try {
        this.grpc.on('data', (data) => {
          try {
            const response = JSON.parse(data.toString('utf8'));
            if (response.result) {
              return resolve(response.result);
            } else if (response.error) {
              return reject(response.error);
            }
          } catch (exception) {
            this.emit('error', `Could not make RPC request: ${exception}\n${data.toString('utf8')}`);
          }
        });

        this.grpc.write(JSON.stringify({
          method: method,
          params: params,
          id: 0
        }), null, '  ');
      } catch (exception) {
        reject(exception);
      }
    });
  }

  /**
   * Make an RPC request through the Lightning UNIX socket.
   * @param {String} method Name of method to call.
   * @param {Array} [params] Array of parameters.
   * @returns {Object|String} Respond from the Lightning node.
   */
  async _makeRPCRequest (method, params = []) {
    return new Promise((resolve, reject) => {
      try {
        const client = net.createConnection({ path: this.settings.path });

        client.on('data', (data) => {
          try {
            const response = JSON.parse(data.toString('utf8'));
            if (response.result) {
              return resolve(response.result);
            } else if (response.error) {
              return reject(response.error);
            }
          } catch (exception) {
            this.emit('error', `Could not make RPC request: ${exception}\n${data.toString('utf8')}`);
          }
        });

        client.write(JSON.stringify({
          method: method,
          params: params,
          id: 0
        }), null, '  ');
      } catch (exception) {
        reject(exception);
      }
    });
  }

  async _syncOracleInfo () {
    if (this.settings.mode === 'rest') {
      const result = await this.rest._GET('/v1/channel/getInfo');

      if (result && result.id) {
        this._state.id = result.id;
        this._state.name = result.alias;
        this._state.network = result.network;
      }

      await this._syncOracleBalance();
      await this._syncOracleChannels();
    }

    return this._state;
  }

  async _syncOracleBalance () {
    if (this.settings.mode === 'rest') {
      const result = await this.rest._GET('/v1/channel/localRemoteBal');
      if (result) {
        this._state.content.balances.spendable = result.totalBalance;
        this._state.content.balances.confirmed = result.confBalance;
        this._state.content.balances.unconfirmed = result.unconfBalance;
        this.commit();
      }
    }

    return this.state;
  }

  async _syncOracleChannels () {
    if (this.settings.mode === 'rest') {
      const result = await this.rest._GET('/v1/channel/listChannels');
      if (!result || !result.map) return this.state;
      this._state.content.channels = result.map((x) => {
        return new Actor(x);
      }).reduce((obj, item) => {
        obj[item.id] = item.state;
        return obj;
      }, {});

      this.commit();
    }

    return this.state;
  }

  async _syncChannels () {
    switch (this.settings.mode) {
      default:
        try {
          const result = await this._makeRPCRequest('listfunds');
          this._state.channels = result.channels;
        } catch (exception) {
          this.emit('error', `Could not sync channels: ${exception}`);
        }
        break;
      case 'rest':
        try {
          const result = await this.rest.get('/v1/channels/listChannels');
          this._state.channels = result.channels;
        } catch (exception) {
          this.emit('error', `Could not sync channels: ${exception}`);
        }
        break;
    }

    this.commit();

    return this;
  }

  async _syncInfo () {
    try {
      const result = await this._makeRPCRequest('getinfo');
      this._state.content.node.id = result.id;
      this._state.content.node.alias = result.alias;
      this._state.content.node.color = result.color;
      this._state.content.blockheight = result.blockheight;
      this.commit();
    } catch (exception) {
      this.emit('error', `Could not sync node info: ${exception}`);
    }

    return this;
  }

  async _sync () {
    await this._syncChannels();
    await this._syncInfo();
    this.emit('sync', this.state);
    return this;
  }
}

module.exports = Lightning;