OverviewFilesCommitsIssuesDocsReleasesPeers

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

461 lines · javascript ref:
/**
 * Credit Manager
 *
 * Manages app developer credit wallets on the HiveRelay network.
 * App devs buy credits (denominated in sats) via Lightning invoices.
 * Credits are deducted per service call based on the pricing engine.
 *
 * Everything operates FREE by default — the free tier is generous.
 * New wallets receive welcome credits (default 1,000 sats) automatically.
 * Developers can contact the relay operator for additional free credits.
 * Relay operators receive welcome credits when their node starts.
 *
 * Flow:
 *   1. App connects → gets free tier access immediately (no wallet needed)
 *   2. App requests wallet → auto-credited with welcome credits
 *   3. App buys more credits via Lightning invoice (optional)
 *   4. On each service call, router middleware deducts from wallet
 *   5. If balance hits zero, app falls back to free-tier limits (still works!)
 *
 * Persistence:
 *   Wallets are serialized to disk and restored on startup.
 *   In production, this would be backed by a proper database.
 */

import { EventEmitter } from 'events'
import { readFile, writeFile, rename, unlink, mkdir } from 'fs/promises'
import { dirname } from 'path'

export class CreditManager extends EventEmitter {
  constructor (opts = {}) {
    super()
    // appPubkey -> Wallet
    this.wallets = new Map()
    this.storagePath = opts.storagePath || null
    this.persistence = opts.persistence || null
    this.minTopUp = opts.minTopUp || 100 // minimum 100 sats top-up
    this.maxBalance = opts.maxBalance || 100_000_000 // 1 BTC max balance
    this.bonusSchedule = opts.bonusSchedule || DEFAULT_BONUS_SCHEDULE
    this.welcomeCredits = opts.welcomeCredits != null ? opts.welcomeCredits : 1000

    // If a persistence store is supplied, hydrate from it immediately
    if (this.persistence) {
      for (const [key, wallet] of this.persistence.entries()) {
        this.wallets.set(key, wallet)
      }
    }
  }

  _persist (appPubkey) {
    if (!this.persistence) return
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) {
      try { this.persistence.delete(appPubkey) } catch (_) {}
      return
    }
    try {
      // Only persist last 1000 transactions to keep WAL lines bounded
      const snapshot = { ...wallet, transactions: wallet.transactions.slice(-1000) }
      this.persistence.set(appPubkey, snapshot)
    } catch (err) {
      this.emit('persist-error', { app: appPubkey, error: err.message })
    }
  }

  /**
   * Create or get a wallet for an app.
   */
  getOrCreateWallet (appPubkey) {
    let wallet = this.wallets.get(appPubkey)
    if (wallet) return wallet

    wallet = {
      appPubkey,
      balance: 0,
      totalDeposited: 0,
      totalSpent: 0,
      totalBonusReceived: 0,
      welcomeCreditsReceived: 0,
      createdAt: Date.now(),
      lastActivity: Date.now(),
      transactions: [],
      frozen: false
    }

    this.wallets.set(appPubkey, wallet)

    // Auto-credit welcome credits to new wallets
    if (this.welcomeCredits > 0) {
      wallet.balance += this.welcomeCredits
      wallet.welcomeCreditsReceived = this.welcomeCredits
      wallet.transactions.push({
        id: this._txId(),
        type: 'welcome',
        amount: this.welcomeCredits,
        balance: wallet.balance,
        timestamp: Date.now(),
        note: 'Welcome credits — everything starts free on HiveRelay'
      })
      this.emit('welcome-credits', { app: appPubkey, amount: this.welcomeCredits })
    }

    this.emit('wallet-created', { app: appPubkey, welcomeCredits: this.welcomeCredits })
    this._persist(appPubkey)
    return wallet
  }

  /**
   * Credit an app's wallet (after Lightning invoice settlement).
   * Returns the actual amount credited (including any bonus).
   */
  topUp (appPubkey, amountSats, opts = {}) {
    if (amountSats < this.minTopUp) {
      throw new Error(`MIN_TOPUP: minimum top-up is ${this.minTopUp} sats`)
    }

    const wallet = this.getOrCreateWallet(appPubkey)

    if (wallet.frozen) {
      throw new Error('WALLET_FROZEN: wallet is frozen, contact support')
    }

    // Calculate volume bonus
    const bonus = this._calculateBonus(amountSats)
    const totalCredit = amountSats + bonus

    if (wallet.balance + totalCredit > this.maxBalance) {
      throw new Error(`MAX_BALANCE: would exceed maximum balance of ${this.maxBalance} sats`)
    }

    wallet.balance += totalCredit
    wallet.totalDeposited += amountSats
    wallet.totalBonusReceived += bonus
    wallet.lastActivity = Date.now()

    const tx = {
      id: this._txId(),
      type: 'deposit',
      amount: amountSats,
      bonus,
      totalCredit,
      balance: wallet.balance,
      timestamp: Date.now(),
      invoiceId: opts.invoiceId || null,
      paymentHash: opts.paymentHash || null
    }
    wallet.transactions.push(tx)

    this.emit('credit-added', {
      app: appPubkey,
      amount: amountSats,
      bonus,
      totalCredit,
      balance: wallet.balance
    })

    this._persist(appPubkey)
    return tx
  }

  /**
   * Deduct credits for a service call.
   * Returns { success, cost, balance } or throws if insufficient.
   */
  deduct (appPubkey, costSats, route, meta = {}) {
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) {
      return { success: false, reason: 'NO_WALLET', cost: costSats, balance: 0 }
    }

    if (wallet.frozen) {
      return { success: false, reason: 'WALLET_FROZEN', cost: costSats, balance: wallet.balance }
    }

    if (wallet.balance < costSats) {
      this.emit('insufficient-credits', {
        app: appPubkey,
        cost: costSats,
        balance: wallet.balance,
        route
      })
      return { success: false, reason: 'INSUFFICIENT_CREDITS', cost: costSats, balance: wallet.balance }
    }

    wallet.balance -= costSats
    wallet.totalSpent += costSats
    wallet.lastActivity = Date.now()

    const tx = {
      id: this._txId(),
      type: 'deduction',
      amount: -costSats,
      route,
      balance: wallet.balance,
      timestamp: Date.now(),
      meta
    }
    wallet.transactions.push(tx)

    // Trim transaction history to last 10000 entries
    if (wallet.transactions.length > 10000) {
      wallet.transactions = wallet.transactions.slice(-10000)
    }

    this.emit('credit-deducted', {
      app: appPubkey,
      cost: costSats,
      route,
      balance: wallet.balance
    })

    this._persist(appPubkey)
    return { success: true, cost: costSats, balance: wallet.balance }
  }

  /**
   * Get wallet balance.
   */
  getBalance (appPubkey) {
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) return 0
    return wallet.balance
  }

  /**
   * Get full wallet summary.
   */
  getWallet (appPubkey) {
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) return null

    return {
      appPubkey: wallet.appPubkey,
      balance: wallet.balance,
      totalDeposited: wallet.totalDeposited,
      totalSpent: wallet.totalSpent,
      totalBonusReceived: wallet.totalBonusReceived,
      welcomeCreditsReceived: wallet.welcomeCreditsReceived || 0,
      frozen: wallet.frozen,
      createdAt: wallet.createdAt,
      lastActivity: wallet.lastActivity,
      transactionCount: wallet.transactions.length
    }
  }

  /**
   * Get transaction history (paginated).
   */
  getTransactions (appPubkey, opts = {}) {
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) return { transactions: [], total: 0 }

    const limit = opts.limit || 50
    const offset = opts.offset || 0
    const txs = wallet.transactions.slice().reverse()

    return {
      transactions: txs.slice(offset, offset + limit),
      total: txs.length,
      limit,
      offset
    }
  }

  /**
   * Freeze a wallet (admin action — suspected abuse).
   */
  freezeWallet (appPubkey, reason) {
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) throw new Error('NO_WALLET')
    wallet.frozen = true
    wallet.transactions.push({
      id: this._txId(),
      type: 'freeze',
      amount: 0,
      balance: wallet.balance,
      timestamp: Date.now(),
      reason
    })
    this.emit('wallet-frozen', { app: appPubkey, reason })
    this._persist(appPubkey)
  }

  /**
   * Unfreeze a wallet.
   */
  unfreezeWallet (appPubkey) {
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) throw new Error('NO_WALLET')
    wallet.frozen = false
    wallet.transactions.push({
      id: this._txId(),
      type: 'unfreeze',
      amount: 0,
      balance: wallet.balance,
      timestamp: Date.now()
    })
    this.emit('wallet-unfrozen', { app: appPubkey })
    this._persist(appPubkey)
  }

  /**
   * Grant free credits to an app (operator/admin action).
   * Used when devs contact the relay operator to request credits.
   */
  grantCredits (appPubkey, amountSats, reason) {
    if (amountSats <= 0) throw new Error('INVALID_AMOUNT: must be positive')
    const wallet = this.getOrCreateWallet(appPubkey)

    if (wallet.frozen) {
      throw new Error('WALLET_FROZEN: wallet is frozen')
    }

    if (wallet.balance + amountSats > this.maxBalance) {
      throw new Error(`MAX_BALANCE: would exceed maximum balance of ${this.maxBalance} sats`)
    }

    wallet.balance += amountSats
    wallet.lastActivity = Date.now()

    const tx = {
      id: this._txId(),
      type: 'grant',
      amount: amountSats,
      balance: wallet.balance,
      timestamp: Date.now(),
      reason: reason || 'Operator credit grant'
    }
    wallet.transactions.push(tx)

    this.emit('credits-granted', {
      app: appPubkey,
      amount: amountSats,
      balance: wallet.balance,
      reason
    })

    this._persist(appPubkey)
    return tx
  }

  /**
   * Check if an app has enough credits for a given cost.
   * Non-destructive check (no deduction).
   */
  canAfford (appPubkey, costSats) {
    const wallet = this.wallets.get(appPubkey)
    if (!wallet) return false
    if (wallet.frozen) return false
    return wallet.balance >= costSats
  }

  /**
   * Aggregate stats across all wallets.
   */
  stats () {
    let totalWallets = 0
    let totalBalance = 0
    let totalDeposited = 0
    let totalSpent = 0
    let totalWelcomeCredits = 0
    let frozenWallets = 0

    for (const wallet of this.wallets.values()) {
      totalWallets++
      totalBalance += wallet.balance
      totalDeposited += wallet.totalDeposited
      totalSpent += wallet.totalSpent
      totalWelcomeCredits += wallet.welcomeCreditsReceived || 0
      if (wallet.frozen) frozenWallets++
    }

    return {
      totalWallets,
      totalBalance,
      totalDeposited,
      totalSpent,
      totalWelcomeCredits,
      welcomeCreditsPerWallet: this.welcomeCredits,
      frozenWallets,
      avgBalance: totalWallets > 0 ? Math.floor(totalBalance / totalWallets) : 0
    }
  }

  /**
   * Persist wallets to disk.
   */
  async save () {
    if (!this.storagePath) return

    const data = {}
    for (const [key, wallet] of this.wallets) {
      data[key] = {
        ...wallet,
        // Only persist last 1000 transactions
        transactions: wallet.transactions.slice(-1000)
      }
    }

    const tmpPath = this.storagePath + '.tmp'
    try {
      await mkdir(dirname(this.storagePath), { recursive: true })
      // Atomic write (tmp + rename) so a crash mid-write can't corrupt the
      // wallet-balance file and silently zero everyone's credits on reload.
      await writeFile(tmpPath, JSON.stringify(data, null, 2))
      await rename(tmpPath, this.storagePath)
    } catch (err) {
      try { await unlink(tmpPath) } catch (_) {}
      this.emit('save-error', { error: err.message })
    }
  }

  /**
   * Load wallets from disk.
   */
  async load () {
    if (!this.storagePath) return

    try {
      const raw = await readFile(this.storagePath, 'utf8')
      const data = JSON.parse(raw)
      for (const [key, wallet] of Object.entries(data)) {
        this.wallets.set(key, wallet)
      }
      this.emit('loaded', { wallets: this.wallets.size })
    } catch (err) {
      if (err.code !== 'ENOENT') {
        this.emit('load-error', { error: err.message })
      }
    }
  }

  /**
   * Calculate volume bonus for a deposit.
   * Bigger deposits get bonus credits — incentivizes bulk purchases.
   */
  _calculateBonus (amountSats) {
    for (let i = this.bonusSchedule.length - 1; i >= 0; i--) {
      const tier = this.bonusSchedule[i]
      if (amountSats >= tier.minDeposit) {
        return Math.floor(amountSats * tier.bonusPct / 100)
      }
    }
    return 0
  }

  _txId () {
    return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
  }
}

/**
 * Volume bonus schedule.
 * Bigger top-ups get bonus credits.
 */
const DEFAULT_BONUS_SCHEDULE = [
  { minDeposit: 10_000, bonusPct: 5 }, // 10k sats → 5% bonus
  { minDeposit: 100_000, bonusPct: 10 }, // 100k sats → 10% bonus
  { minDeposit: 1_000_000, bonusPct: 15 }, // 1M sats → 15% bonus
  { minDeposit: 10_000_000, bonusPct: 20 } // 10M sats → 20% bonus
]