OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / incentive / payment / lightning-provider.js

238 lines · javascript ref:
/**
 * LND Lightning Payment Provider
 *
 * Connects to an LND node via gRPC to handle real Lightning payments.
 * Plugs into the PaymentManager for settlement of relay earnings.
 *
 * Requires:
 *   - Running LND node with gRPC enabled
 *   - Admin macaroon for payment operations
 *   - TLS certificate for secure connection
 */

import { EventEmitter } from 'events'
import { readFileSync, existsSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'

export class LightningProvider extends EventEmitter {
  constructor (opts = {}) {
    super()
    this.rpcUrl = opts.rpcUrl || 'localhost:10009'
    this.macaroonPath = opts.macaroonPath || null
    this.certPath = opts.certPath || null
    this.timeout = opts.timeout || 30_000
    this.network = opts.network || 'mainnet'
    this.client = null
    this.connected = false
  }

  async connect () {
    const grpc = await import('@grpc/grpc-js')
    const protoLoader = await import('@grpc/proto-loader')

    // Load LND proto definition
    // LND ships rpc.proto — we use the google proto path convention
    const protoPath = this._resolveProtoPath()

    const packageDef = protoLoader.loadSync(protoPath, {
      keepCase: false,
      longs: Number,
      enums: String,
      defaults: true,
      oneofs: true
    })

    const lnrpc = grpc.loadPackageDefinition(packageDef).lnrpc

    // Build credentials
    const certData = this.certPath ? readFileSync(this.certPath) : null
    const sslCreds = certData
      ? grpc.credentials.createSsl(certData)
      : grpc.credentials.createInsecure()

    const macaroonData = this.macaroonPath
      ? readFileSync(this.macaroonPath).toString('hex')
      : null

    let creds = sslCreds
    if (macaroonData) {
      const macaroonCreds = grpc.credentials.createFromMetadataGenerator(
        (_, cb) => {
          const metadata = new grpc.Metadata()
          metadata.add('macaroon', macaroonData)
          cb(null, metadata)
        }
      )
      creds = grpc.credentials.combineChannelCredentials(sslCreds, macaroonCreds)
    }

    this.client = new lnrpc.Lightning(this.rpcUrl, creds)

    // Verify connection
    await this._call('getInfo', {})
    this.connected = true
    this.emit('connected')
  }

  async pay (invoice, amount, opts = {}) {
    if (!this.connected) throw new Error('Provider not connected')

    const asset = opts.asset || 'BTC'
    if (asset !== 'BTC' && !this._taprootAssetsEnabled) {
      // USDt-over-Lightning lives on Taproot Assets. This provider doesn't
      // ship that integration yet; flag it clearly rather than silently
      // paying a BTC invoice. Wire in tapd/litd here when we integrate.
      throw new Error(
        `LightningProvider: asset '${asset}' requires Taproot Assets support ` +
        '(not yet enabled). Pay via BTC or use a provider that supports this asset.'
      )
    }

    const request = { paymentRequest: invoice }
    if (amount) request.amt = amount

    const response = await this._call('sendPaymentSync', request)

    if (response.paymentError) {
      const err = new Error(`Payment failed: ${response.paymentError}`)
      this.emit('payment-failed', { invoice, error: response.paymentError })
      throw err
    }

    const result = {
      invoice,
      amount,
      asset,
      preimage: response.paymentPreimage
        ? Buffer.from(response.paymentPreimage).toString('hex')
        : null,
      timestamp: Date.now()
    }

    this.emit('payment-sent', result)
    return result
  }

  async createInvoice (amount, memo = '', opts = {}) {
    if (!this.connected) throw new Error('Provider not connected')

    const asset = opts.asset || 'BTC'
    if (asset !== 'BTC' && !this._taprootAssetsEnabled) {
      throw new Error(
        `LightningProvider: asset '${asset}' invoice requires Taproot Assets support ` +
        '(not yet enabled).'
      )
    }

    const response = await this._call('addInvoice', {
      value: amount,
      memo
    })

    const result = {
      bolt11: response.paymentRequest,
      amount,
      asset,
      memo,
      rHash: response.rHash
        ? Buffer.from(response.rHash).toString('hex')
        : null,
      timestamp: Date.now()
    }

    this.emit('invoice-created', result)
    return result
  }

  /**
   * Advertise what this provider can do. Supports the new selectProvider()
   * helper in `./provider.js`. If/when Taproot Assets is wired in,
   * this will surface 'USDT' in `assets`.
   */
  capabilities () {
    return {
      name: 'LightningProvider',
      assets: this._taprootAssetsEnabled ? ['BTC', 'USDT'] : ['BTC'],
      rails: ['lightning'],
      micropayments: true,
      topUpModel: false,
      connected: this.connected
    }
  }

  async getBalance () {
    if (!this.connected) throw new Error('Provider not connected')

    const response = await this._call('channelBalance', {})
    return {
      confirmed: Number(response.balance || 0),
      unconfirmed: Number(response.pendingOpenBalance || 0)
    }
  }

  async getInfo () {
    if (!this.connected) throw new Error('Provider not connected')

    const response = await this._call('getInfo', {})
    return {
      pubkey: response.identityPubkey,
      alias: response.alias,
      channels: {
        active: response.numActiveChannels,
        inactive: response.numInactiveChannels,
        pending: response.numPendingChannels
      },
      blockHeight: response.blockHeight
    }
  }

  async disconnect () {
    if (this.client) {
      this.client.close()
      this.client = null
    }
    this.connected = false
    this.emit('disconnected')
  }

  /**
   * Promisify a gRPC unary call with timeout.
   */
  _call (method, request) {
    return new Promise((resolve, reject) => {
      const deadline = new Date(Date.now() + this.timeout)
      this.client[method](request, { deadline }, (err, response) => {
        if (err) reject(err)
        else resolve(response)
      })
    })
  }

  /**
   * Resolve the LND proto file path.
   * Looks for a bundled proto or falls back to a well-known location.
   */
  _resolveProtoPath () {
    // Check bundled proto first
    const bundled = join(dirname(fileURLToPath(import.meta.url)), 'lnrpc', 'lightning.proto')
    if (existsSync(bundled)) return bundled

    // Common LND install locations
    const candidates = [
      join(process.env.HOME || '', '.lnd', 'rpc.proto'),
      '/usr/local/share/lnd/rpc.proto',
      join(process.env.GOPATH || '', 'src', 'github.com', 'lightningnetwork', 'lnd', 'lnrpc', 'lightning.proto')
    ]

    for (const p of candidates) {
      if (existsSync(p)) return p
    }

    throw new Error(
      'LND proto file not found. Set GOPATH or place lightning.proto in ' +
      dirname(fileURLToPath(import.meta.url)) + '/lnrpc/'
    )
  }
}