{"version":3,"file":"budget-service.mjs","names":[],"sources":["../../../src/services/budget-service.ts"],"sourcesContent":["/**\n * Budget Enforcement Service — per-operation gas+slippage budget tracking.\n *\n * Inspired by Lemon's BudgetTracker/BudgetEnforcer pattern.\n * Tracks cumulative costs across multi-step compound operations and\n * halts execution when a budget threshold is exceeded.\n *\n * Designed to solve the \"agent spends too much gas on a failed swap chain\"\n * problem: if step 1 of a 3-step compound action already burned $X in gas,\n * stop before step 2 if the remaining budget is insufficient.\n *\n * Usage:\n *   const session = budgetService.startSession({ maxGasUsd: 5, maxSlippagePercent: 2 });\n *   budgetService.recordCost(session.id, { gasUsd: 0.42, slippageUsd: 1.20, stepLabel: 'swap ETH→USDC' });\n *   const check = budgetService.checkBudget(session.id);\n *   if (!check.ok) { /* halt operation * / }\n *   budgetService.endSession(session.id);\n */\n\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\nexport interface BudgetLimits {\n  /** Max total gas cost in USD across all steps. Default: 10. */\n  maxGasUsd?: number;\n  /** Max total slippage in USD across all steps. Default: no limit. */\n  maxSlippageUsd?: number;\n  /** Max slippage as a percentage of trade value. Default: 5. */\n  maxSlippagePercent?: number;\n  /** Max total cost (gas + slippage + fees) in USD. Default: 25. */\n  maxTotalCostUsd?: number;\n  /** Max number of on-chain transactions. Default: 10. */\n  maxTransactions?: number;\n}\n\nexport interface CostRecord {\n  timestamp: number;\n  stepLabel: string;\n  gasUsd: number;\n  slippageUsd: number;\n  feesUsd: number;\n  tradeValueUsd: number;\n  txHash?: string;\n}\n\nexport interface BudgetSession {\n  id: string;\n  userId: string;\n  limits: Required<BudgetLimits>;\n  costs: CostRecord[];\n  status: 'active' | 'completed' | 'exceeded' | 'cancelled';\n  startedAt: number;\n  endedAt?: number;\n  label?: string;\n}\n\nexport interface BudgetCheck {\n  ok: boolean;\n  totalGasUsd: number;\n  totalSlippageUsd: number;\n  totalFeesUsd: number;\n  totalCostUsd: number;\n  transactionCount: number;\n  remainingGasUsd: number;\n  remainingTotalUsd: number;\n  remainingTransactions: number;\n  warnings: string[];\n  blockers: string[];\n}\n\n// ─── Default Limits ──────────────────────────────────────────────────────\n\nconst DEFAULT_LIMITS: Required<BudgetLimits> = {\n  maxGasUsd: 10,\n  maxSlippageUsd: Infinity,\n  maxSlippagePercent: 5,\n  maxTotalCostUsd: 25,\n  maxTransactions: 10,\n};\n\n// ─── Budget Service ──────────────────────────────────────────────────────\n\nclass BudgetService {\n  private sessions = new Map<string, BudgetSession>();\n  private userActiveSessions = new Map<string, string>(); // userId → sessionId\n\n  /**\n   * Start a new budget tracking session for a compound operation.\n   */\n  startSession(opts: {\n    userId: string;\n    limits?: BudgetLimits;\n    label?: string;\n  }): BudgetSession {\n    // End any existing active session for this user\n    const existingId = this.userActiveSessions.get(opts.userId);\n    if (existingId) {\n      this.endSession(existingId, 'completed');\n    }\n\n    const id = `budget_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n    const session: BudgetSession = {\n      id,\n      userId: opts.userId,\n      limits: { ...DEFAULT_LIMITS, ...opts.limits },\n      costs: [],\n      status: 'active',\n      startedAt: Date.now(),\n      label: opts.label,\n    };\n\n    this.sessions.set(id, session);\n    this.userActiveSessions.set(opts.userId, id);\n    return session;\n  }\n\n  /**\n   * Record a cost incurred by a step in the operation.\n   */\n  recordCost(sessionId: string, cost: {\n    stepLabel: string;\n    gasUsd?: number;\n    slippageUsd?: number;\n    feesUsd?: number;\n    tradeValueUsd?: number;\n    txHash?: string;\n  }): void {\n    const session = this.sessions.get(sessionId);\n    if (!session || session.status !== 'active') return;\n\n    session.costs.push({\n      timestamp: Date.now(),\n      stepLabel: cost.stepLabel,\n      gasUsd: cost.gasUsd ?? 0,\n      slippageUsd: cost.slippageUsd ?? 0,\n      feesUsd: cost.feesUsd ?? 0,\n      tradeValueUsd: cost.tradeValueUsd ?? 0,\n      txHash: cost.txHash,\n    });\n\n    // After recording, check if any budget limit is now exceeded and\n    // transition the session status. This keeps checkBudget() side-effect-free.\n    const totalGas = session.costs.reduce((s, c) => s + c.gasUsd, 0);\n    const totalSlippage = session.costs.reduce((s, c) => s + c.slippageUsd, 0);\n    const totalFees = session.costs.reduce((s, c) => s + c.feesUsd, 0);\n    const totalCost = totalGas + totalSlippage + totalFees;\n    const txCount = session.costs.filter(c => c.txHash).length;\n\n    const exceeded =\n      totalGas > session.limits.maxGasUsd ||\n      totalCost > session.limits.maxTotalCostUsd ||\n      totalSlippage > session.limits.maxSlippageUsd ||\n      txCount >= session.limits.maxTransactions;\n\n    if (exceeded) {\n      session.status = 'exceeded';\n    }\n  }\n\n  /**\n   * Check whether the session is still within budget.\n   * Returns detailed breakdown with warnings and blockers.\n   */\n  checkBudget(sessionId: string): BudgetCheck {\n    const session = this.sessions.get(sessionId);\n    if (!session) {\n      return {\n        ok: true, // No session = no budget tracking = allow\n        totalGasUsd: 0, totalSlippageUsd: 0, totalFeesUsd: 0,\n        totalCostUsd: 0, transactionCount: 0,\n        remainingGasUsd: Infinity, remainingTotalUsd: Infinity,\n        remainingTransactions: Infinity,\n        warnings: [], blockers: [],\n      };\n    }\n\n    const totalGasUsd = session.costs.reduce((sum, c) => sum + c.gasUsd, 0);\n    const totalSlippageUsd = session.costs.reduce((sum, c) => sum + c.slippageUsd, 0);\n    const totalFeesUsd = session.costs.reduce((sum, c) => sum + c.feesUsd, 0);\n    const totalCostUsd = totalGasUsd + totalSlippageUsd + totalFeesUsd;\n    const transactionCount = session.costs.filter(c => c.txHash).length;\n\n    const warnings: string[] = [];\n    const blockers: string[] = [];\n\n    // Check gas limit\n    if (totalGasUsd > session.limits.maxGasUsd) {\n      blockers.push(\n        `Gas budget exceeded: $${totalGasUsd.toFixed(2)} spent (limit: $${session.limits.maxGasUsd.toFixed(2)})`\n      );\n    } else if (totalGasUsd > session.limits.maxGasUsd * 0.8) {\n      warnings.push(\n        `Gas budget 80% consumed: $${totalGasUsd.toFixed(2)} of $${session.limits.maxGasUsd.toFixed(2)}`\n      );\n    }\n\n    // Check total cost limit\n    if (totalCostUsd > session.limits.maxTotalCostUsd) {\n      blockers.push(\n        `Total cost budget exceeded: $${totalCostUsd.toFixed(2)} spent (limit: $${session.limits.maxTotalCostUsd.toFixed(2)})`\n      );\n    } else if (totalCostUsd > session.limits.maxTotalCostUsd * 0.8) {\n      warnings.push(\n        `Total cost budget 80% consumed: $${totalCostUsd.toFixed(2)} of $${session.limits.maxTotalCostUsd.toFixed(2)}`\n      );\n    }\n\n    // Check slippage limit (USD)\n    if (totalSlippageUsd > session.limits.maxSlippageUsd) {\n      blockers.push(\n        `Slippage budget exceeded: $${totalSlippageUsd.toFixed(2)} lost (limit: $${session.limits.maxSlippageUsd.toFixed(2)})`\n      );\n    }\n\n    // Check slippage limit (percentage per individual trade)\n    for (const cost of session.costs) {\n      if (cost.tradeValueUsd > 0 && cost.slippageUsd > 0) {\n        const slippagePct = (cost.slippageUsd / cost.tradeValueUsd) * 100;\n        if (slippagePct > session.limits.maxSlippagePercent) {\n          warnings.push(\n            `High slippage on \"${cost.stepLabel}\": ${slippagePct.toFixed(1)}% (limit: ${session.limits.maxSlippagePercent}%)`\n          );\n        }\n      }\n    }\n\n    // Check transaction count\n    if (transactionCount >= session.limits.maxTransactions) {\n      blockers.push(\n        `Transaction limit reached: ${transactionCount} of ${session.limits.maxTransactions}`\n      );\n    }\n\n    return {\n      ok: blockers.length === 0,\n      totalGasUsd,\n      totalSlippageUsd,\n      totalFeesUsd,\n      totalCostUsd,\n      transactionCount,\n      remainingGasUsd: Math.max(0, session.limits.maxGasUsd - totalGasUsd),\n      remainingTotalUsd: Math.max(0, session.limits.maxTotalCostUsd - totalCostUsd),\n      remainingTransactions: Math.max(0, session.limits.maxTransactions - transactionCount),\n      warnings,\n      blockers,\n    };\n  }\n\n  /**\n   * End a budget session.\n   */\n  endSession(sessionId: string, status: 'completed' | 'cancelled' = 'completed'): BudgetSession | null {\n    const session = this.sessions.get(sessionId);\n    if (!session) return null;\n\n    if (session.status === 'active') {\n      session.status = status;\n    }\n    session.endedAt = Date.now();\n\n    // Clean up user mapping\n    if (this.userActiveSessions.get(session.userId) === sessionId) {\n      this.userActiveSessions.delete(session.userId);\n    }\n\n    // Persist to disk for audit trail\n    this.persistSession(session);\n\n    return session;\n  }\n\n  /**\n   * Get the active budget session for a user (if any).\n   */\n  getActiveSession(userId: string): BudgetSession | null {\n    const sessionId = this.userActiveSessions.get(userId);\n    if (!sessionId) return null;\n    const session = this.sessions.get(sessionId);\n    if (!session || session.status !== 'active') return null;\n    return session;\n  }\n\n  /**\n   * Get a session by ID.\n   */\n  getSession(sessionId: string): BudgetSession | null {\n    return this.sessions.get(sessionId) ?? null;\n  }\n\n  /**\n   * Format a budget check result as a human-readable string.\n   */\n  formatBudgetCheck(check: BudgetCheck): string {\n    const lines: string[] = [];\n\n    lines.push(`Cost so far: $${check.totalCostUsd.toFixed(2)} (gas: $${check.totalGasUsd.toFixed(2)}, slippage: $${check.totalSlippageUsd.toFixed(2)}, fees: $${check.totalFeesUsd.toFixed(2)})`);\n    lines.push(`Transactions: ${check.transactionCount}`);\n    lines.push(`Remaining budget: $${check.remainingTotalUsd.toFixed(2)} total, $${check.remainingGasUsd.toFixed(2)} gas`);\n\n    if (check.warnings.length > 0) {\n      lines.push('', 'Warnings:');\n      for (const w of check.warnings) lines.push(`  - ${w}`);\n    }\n    if (check.blockers.length > 0) {\n      lines.push('', 'BLOCKED:');\n      for (const b of check.blockers) lines.push(`  - ${b}`);\n    }\n\n    return lines.join('\\n');\n  }\n\n  // ── Persistence ──────────────────────────────────────────────────────\n\n  private getAuditDir(): string {\n    return process.env.OPENCLAWNCH_TX_DIR\n      ? join(process.env.OPENCLAWNCH_TX_DIR, '..', 'budget-audit')\n      : join(process.env.HOME ?? '/tmp', '.openclawnch', 'budget-audit');\n  }\n\n  private persistSession(session: BudgetSession): void {\n    try {\n      const dir = this.getAuditDir();\n      if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\n      const filename = `${session.id}.json`;\n      writeFileSync(join(dir, filename), JSON.stringify(session, null, 2), 'utf8');\n    } catch {\n      // Best effort — don't crash on audit write failure\n    }\n  }\n}\n\n// ─── Singleton ───────────────────────────────────────────────────────────\n\nlet _instance: BudgetService | null = null;\n\nexport function getBudgetService(): BudgetService {\n  if (!_instance) {\n    _instance = new BudgetService();\n  }\n  return _instance;\n}\n\nexport function resetBudgetService(): void {\n  _instance = null;\n}\n"],"mappings":"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"}