OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / incentive / credits / invoice.js

313 lines · javascript ref:
/**
 * Invoice Manager
 *
 * Manages the full lifecycle of Lightning invoices for credit purchases:
 *   1. App requests credits → generate Lightning invoice
 *   2. Invoice is returned to app (bolt11 string)
 *   3. App pays via any Lightning wallet
 *   4. Invoice settles → CreditManager tops up wallet
 *
 * Supports both:
 *   - LND invoice subscription (real-time settlement detection)
 *   - Polling (for providers without subscription support)
 *
 * Invoice states: pending → settled | expired | cancelled
 */

import { EventEmitter } from 'events'
import { randomBytes } from 'crypto'

const INVOICE_EXPIRY_MS = 15 * 60 * 1000 // 15 minutes default
const POLL_INTERVAL_MS = 5_000 // Check every 5s for pending invoices
const MAX_PENDING = 100 // Max concurrent pending invoices per relay

export class InvoiceManager extends EventEmitter {
  constructor (opts = {}) {
    super()
    this.provider = opts.provider || null // LightningProvider or MockProvider
    this.creditManager = opts.creditManager || null
    this.expiryMs = opts.expiryMs || INVOICE_EXPIRY_MS
    this.pollIntervalMs = opts.pollIntervalMs || POLL_INTERVAL_MS

    // invoiceId -> Invoice
    this.invoices = new Map()
    // paymentHash -> invoiceId (for settlement lookup)
    this.hashIndex = new Map()

    this._pollTimer = null
    this._started = false
  }

  /**
   * Start the invoice manager.
   * Begins polling for settled invoices if no subscription available.
   */
  start () {
    if (this._started) return
    this._started = true

    // Start polling for invoice settlements
    this._pollTimer = setInterval(() => {
      this._checkPendingInvoices().catch(err => {
        this.emit('poll-error', { error: err.message })
      })
    }, this.pollIntervalMs)

    // Also run expiry cleanup
    this._expiryTimer = setInterval(() => {
      this._expirePending()
    }, 60_000) // Check every minute

    this.emit('started')
  }

  /**
   * Stop the invoice manager.
   */
  stop () {
    if (this._pollTimer) clearInterval(this._pollTimer)
    if (this._expiryTimer) clearInterval(this._expiryTimer)
    this._started = false
    this.emit('stopped')
  }

  /**
   * Create a new invoice for an app to purchase credits.
   *
   * @param {string} appPubkey - The app's public key
   * @param {number} amountSats - Amount in sats to invoice
   * @param {object} [opts] - Options
   * @param {string} [opts.memo] - Invoice memo
   * @returns {Promise<Invoice>}
   */
  async createInvoice (appPubkey, amountSats, opts = {}) {
    if (!this.provider) throw new Error('NO_PROVIDER: payment provider not configured')
    if (!this.provider.connected) throw new Error('PROVIDER_DISCONNECTED')
    if (amountSats < 100) throw new Error('MIN_AMOUNT: minimum invoice is 100 sats')
    if (amountSats > 100_000_000) throw new Error('MAX_AMOUNT: maximum invoice is 1 BTC')

    // Check pending invoice limit
    let pendingCount = 0
    for (const inv of this.invoices.values()) {
      if (inv.status === 'pending') pendingCount++
    }
    if (pendingCount >= MAX_PENDING) {
      throw new Error('MAX_PENDING: too many pending invoices')
    }

    const memo = opts.memo || `HiveRelay credits: ${amountSats} sats for ${appPubkey.slice(0, 12)}...`
    const providerInvoice = await this.provider.createInvoice(amountSats, memo)

    const invoice = {
      id: this._invoiceId(),
      appPubkey,
      amountSats,
      bolt11: providerInvoice.bolt11,
      paymentHash: providerInvoice.rHash || null,
      memo,
      status: 'pending', // pending | settled | expired | cancelled
      createdAt: Date.now(),
      expiresAt: Date.now() + this.expiryMs,
      settledAt: null,
      creditTx: null // CreditManager transaction ID once credited
    }

    this.invoices.set(invoice.id, invoice)
    if (invoice.paymentHash) {
      this.hashIndex.set(invoice.paymentHash, invoice.id)
    }

    this.emit('invoice-created', {
      id: invoice.id,
      app: appPubkey,
      amount: amountSats,
      bolt11: invoice.bolt11,
      expiresAt: invoice.expiresAt
    })

    return {
      id: invoice.id,
      bolt11: invoice.bolt11,
      amount: amountSats,
      expiresAt: invoice.expiresAt,
      status: 'pending'
    }
  }

  /**
   * Get invoice status.
   */
  getInvoice (invoiceId) {
    const inv = this.invoices.get(invoiceId)
    if (!inv) return null

    return {
      id: inv.id,
      appPubkey: inv.appPubkey,
      amountSats: inv.amountSats,
      bolt11: inv.bolt11,
      status: inv.status,
      createdAt: inv.createdAt,
      expiresAt: inv.expiresAt,
      settledAt: inv.settledAt
    }
  }

  /**
   * Get all invoices for an app.
   */
  getAppInvoices (appPubkey, opts = {}) {
    const result = []
    for (const inv of this.invoices.values()) {
      if (inv.appPubkey === appPubkey) {
        result.push({
          id: inv.id,
          amountSats: inv.amountSats,
          status: inv.status,
          createdAt: inv.createdAt,
          settledAt: inv.settledAt
        })
      }
    }

    // Sort newest first
    result.sort((a, b) => b.createdAt - a.createdAt)

    const limit = opts.limit || 50
    return result.slice(0, limit)
  }

  /**
   * Cancel a pending invoice.
   */
  cancelInvoice (invoiceId) {
    const inv = this.invoices.get(invoiceId)
    if (!inv) throw new Error('INVOICE_NOT_FOUND')
    if (inv.status !== 'pending') throw new Error('INVOICE_NOT_PENDING')

    inv.status = 'cancelled'
    this.emit('invoice-cancelled', { id: inv.id, app: inv.appPubkey })
    return { id: inv.id, status: 'cancelled' }
  }

  /**
   * Manually mark an invoice as settled.
   * Used when settlement is detected externally (e.g., webhook, subscription).
   */
  async settleInvoice (invoiceIdOrHash, opts = {}) {
    let inv

    // Look up by hash or ID
    if (this.hashIndex.has(invoiceIdOrHash)) {
      const id = this.hashIndex.get(invoiceIdOrHash)
      inv = this.invoices.get(id)
    } else {
      inv = this.invoices.get(invoiceIdOrHash)
    }

    if (!inv) throw new Error('INVOICE_NOT_FOUND')
    if (inv.status === 'settled') return { id: inv.id, status: 'settled', alreadySettled: true }
    if (inv.status !== 'pending') throw new Error('INVOICE_NOT_PENDING: status is ' + inv.status)

    inv.status = 'settled'
    inv.settledAt = Date.now()

    // Credit the app's wallet
    if (this.creditManager) {
      const tx = this.creditManager.topUp(inv.appPubkey, inv.amountSats, {
        invoiceId: inv.id,
        paymentHash: inv.paymentHash
      })
      inv.creditTx = tx.id
    }

    this.emit('invoice-settled', {
      id: inv.id,
      app: inv.appPubkey,
      amount: inv.amountSats,
      creditTx: inv.creditTx
    })

    return { id: inv.id, status: 'settled', creditTx: inv.creditTx }
  }

  /**
   * Check pending invoices against the payment provider.
   * Called on a polling interval.
   */
  async _checkPendingInvoices () {
    if (!this.provider || !this.provider.connected) return

    for (const inv of this.invoices.values()) {
      if (inv.status !== 'pending') continue
      if (Date.now() > inv.expiresAt) continue // Will be expired by _expirePending

      try {
        // Check if the provider has a lookupInvoice method
        if (typeof this.provider.lookupInvoice === 'function') {
          const lookup = await this.provider.lookupInvoice(inv.paymentHash)
          if (lookup && lookup.settled) {
            await this.settleInvoice(inv.id)
          }
        }
      } catch {
        // Ignore lookup errors for individual invoices
      }
    }
  }

  /**
   * Expire invoices past their expiry time.
   */
  _expirePending () {
    const now = Date.now()
    for (const inv of this.invoices.values()) {
      if (inv.status === 'pending' && now > inv.expiresAt) {
        inv.status = 'expired'
        this.emit('invoice-expired', { id: inv.id, app: inv.appPubkey })
      }
    }
  }

  /**
   * Stats for monitoring.
   */
  stats () {
    let pending = 0
    let settled = 0
    let expired = 0
    let cancelled = 0
    let totalSettledSats = 0

    for (const inv of this.invoices.values()) {
      if (inv.status === 'pending') pending++
      else if (inv.status === 'settled') { settled++; totalSettledSats += inv.amountSats } else if (inv.status === 'expired') expired++
      else if (inv.status === 'cancelled') cancelled++
    }

    return { total: this.invoices.size, pending, settled, expired, cancelled, totalSettledSats }
  }

  /**
   * Cleanup old settled/expired/cancelled invoices (older than 30 days).
   */
  cleanup (maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
    const cutoff = Date.now() - maxAgeMs
    let cleaned = 0

    for (const [id, inv] of this.invoices) {
      if (inv.status !== 'pending' && inv.createdAt < cutoff) {
        this.invoices.delete(id)
        if (inv.paymentHash) this.hashIndex.delete(inv.paymentHash)
        cleaned++
      }
    }

    return { cleaned }
  }

  _invoiceId () {
    return 'inv_' + randomBytes(12).toString('hex')
  }
}