/** * Auto-topup for the 0G Router Payment Layer credit (the opt-in Router brain's * funding). Deliberately SEPARATE from {@link AutoTopupManager}: that one * refills a per-provider serving-broker envelope (deposit + transfer, two txs) * and its ctor requires a `compute.provider`. The Router model is a SINGLE * credit balance funded by ONE `depositFor` tx, and deposits are * NON-RECOVERABLE, so this manager deposits the MINIMUM and never more than * the cap. It reuses the {@link AutoTopupEvent} shape so the gateway wires its * notifications identically. */ import type { AutoTopupEvent } from './auto-topup' export interface RouterAutoTopupOpts { enabled?: boolean pollIntervalMs?: number /** Refill when the credit balance (0G) drops below this. Default 0.5. */ lowThreshold?: number /** Amount (0G) to deposit per topup. Default 0.5 (minimum, deposits are non-recoverable). */ topUpAmount?: number /** Max successful topups per UTC day. Default 5. */ maxPerDay?: number /** Emit `wallet-low` when the agent EOA (0G) crosses below this. Default 2.0. */ walletNotifyThreshold?: number /** Don't deposit if it would leave the EOA below this (gas buffer, 0G). Default 0.1. */ minRetainedAfterTopup?: number } export interface RouterAutoTopupDeps { /** Payment Layer credit balance in 0G; null on read failure. */ getCreditOg: () => Promise /** Agent EOA native balance in 0G; null on read failure. */ getWalletOg: () => Promise /** Deposit `amountOg` into the Payment Layer; resolves the tx hash. */ deposit: (amountOg: number) => Promise onEvent: (ev: AutoTopupEvent) => void /** Injectable clock for tests (ms). Defaults to Date.now. */ now?: () => number } const DEFAULTS = { enabled: true, pollIntervalMs: 5 * 60 * 1000, lowThreshold: 0.5, topUpAmount: 0.5, maxPerDay: 5, walletNotifyThreshold: 2.0, minRetainedAfterTopup: 0.1, } as const function utcDayKey(ts: number): string { const d = new Date(ts) return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}` } export class RouterAutoTopupManager { #opts: Required #deps: RouterAutoTopupDeps #timer: ReturnType | null = null #stopping = false #dayCounts = new Map() #lastWalletOg: number | null = null #insufficientWalletAt: number | null = null #insufficientWalletCooldownMs = 10 * 60 * 1000 // Guards a deposit that is broadcast-but-not-yet-mined so an overlapping // poll tick (or a tick firing while the previous deposit awaits its receipt) // can't fire a SECOND non-recoverable deposit on a stale balance read. #depositing = false // Emit the daily-cap notice once per UTC day, not on every 5-min tick. #dailyCapNotifiedFor: string | null = null constructor(opts: RouterAutoTopupOpts, deps: RouterAutoTopupDeps) { this.#opts = { enabled: opts.enabled ?? DEFAULTS.enabled, pollIntervalMs: opts.pollIntervalMs ?? DEFAULTS.pollIntervalMs, lowThreshold: opts.lowThreshold ?? DEFAULTS.lowThreshold, topUpAmount: opts.topUpAmount ?? DEFAULTS.topUpAmount, maxPerDay: opts.maxPerDay ?? DEFAULTS.maxPerDay, walletNotifyThreshold: opts.walletNotifyThreshold ?? DEFAULTS.walletNotifyThreshold, minRetainedAfterTopup: opts.minRetainedAfterTopup ?? DEFAULTS.minRetainedAfterTopup, } this.#deps = deps } private now(): number { return this.#deps.now ? this.#deps.now() : Date.now() } start(): void { if (this.#timer || !this.#opts.enabled) return void this.tick().catch(() => {}) this.#timer = setInterval(() => void this.tick().catch(() => {}), this.#opts.pollIntervalMs) } stop(): void { this.#stopping = true if (this.#timer) { clearInterval(this.#timer) this.#timer = null } } /** Public for tests + `anima topup status`. One pass over credit + wallet. */ async tick(): Promise { if (this.#stopping) return const ts = this.now() const credit = await this.#deps.getCreditOg() if (credit == null) { this.#deps.onEvent({ kind: 'topup-skipped', ts, message: 'router auto-topup: Payment Layer balance read failed', data: { reason: 'balance-read-error' }, }) return } const wallet = await this.#deps.getWalletOg() if (wallet != null) this.#maybeWalletLow(wallet, ts) if (credit >= this.#opts.lowThreshold) return // A previous deposit is broadcast but not yet mined: skip this tick so we // don't double-deposit non-recoverable credit on a stale balance read. if (this.#depositing) return const dayKey = utcDayKey(ts) const todayCount = this.#dayCounts.get(dayKey) ?? 0 if (todayCount >= this.#opts.maxPerDay) { if (this.#dailyCapNotifiedFor !== dayKey) { this.#dailyCapNotifiedFor = dayKey this.#deps.onEvent({ kind: 'topup-failed', ts, message: `router credit low (${credit.toFixed(3)} 0G) but daily cap reached (${todayCount}/${this.#opts.maxPerDay})`, data: { envelope: 'router', creditBalance: credit, dailyCount: todayCount, reason: 'daily-cap', }, }) } return } if (wallet != null && wallet < this.#opts.topUpAmount + this.#opts.minRetainedAfterTopup) { const recentlyFailed = this.#insufficientWalletAt != null && ts - this.#insufficientWalletAt < this.#insufficientWalletCooldownMs if (recentlyFailed) return this.#insufficientWalletAt = ts this.#deps.onEvent({ kind: 'topup-failed', ts, message: `router credit low (${credit.toFixed(3)} 0G) but agent wallet too thin (${wallet.toFixed(3)} 0G)`, data: { envelope: 'router', creditBalance: credit, walletBalance: wallet, reason: 'insufficient-wallet', }, }) return } this.#insufficientWalletAt = null let depositTx: string | undefined this.#depositing = true try { depositTx = await this.#deps.deposit(this.#opts.topUpAmount) } catch (err) { this.#deps.onEvent({ kind: 'topup-failed', ts, message: `router credit topup failed: ${(err as Error)?.message?.slice(0, 200) ?? 'unknown'}`, data: { envelope: 'router', creditBalance: credit, reason: 'tx-failed' }, }) return } finally { this.#depositing = false } this.#dayCounts.set(dayKey, todayCount + 1) this.#deps.onEvent({ kind: 'topup-fired', ts, message: `router credit topped up by ${this.#opts.topUpAmount} 0G (was ${credit.toFixed(3)} 0G; NON-recoverable)`, data: { envelope: 'router', creditBalanceBefore: credit, topUpAmount: this.#opts.topUpAmount, depositTx, dailyCount: todayCount + 1, dailyCap: this.#opts.maxPerDay, }, }) } #maybeWalletLow(walletOg: number, ts: number): void { const wasAbove = this.#lastWalletOg == null || this.#lastWalletOg >= this.#opts.walletNotifyThreshold const nowBelow = walletOg < this.#opts.walletNotifyThreshold this.#lastWalletOg = walletOg if (wasAbove && nowBelow) { this.#deps.onEvent({ kind: 'wallet-low', ts, message: `agent wallet below ${this.#opts.walletNotifyThreshold} 0G (current: ${walletOg.toFixed(3)} 0G)`, data: { walletBalance: walletOg, notifyThreshold: this.#opts.walletNotifyThreshold }, }) } } }