import { describe, expect, it } from 'bun:test' import type { AutoTopupEvent } from './auto-topup' import { type RouterAutoTopupDeps, RouterAutoTopupManager } from './router-topup' const FIXED_TS = 1_718_000_000_000 // fixed clock (deterministic UTC-day key) function harness( over: Partial<{ credit: number | null wallet: number | null depositImpl: (amountOg: number) => Promise }> = {}, ) { const events: AutoTopupEvent[] = [] let deposits = 0 const deps: RouterAutoTopupDeps = { getCreditOg: async () => (over.credit === undefined ? 0.1 : over.credit), getWalletOg: async () => (over.wallet === undefined ? 5 : over.wallet), deposit: over.depositImpl ?? (async () => { deposits++ return `0xhash${deposits}` }), onEvent: ev => events.push(ev), now: () => FIXED_TS, } return { events, deps, deposits: () => deposits } } describe('RouterAutoTopupManager.tick', () => { it('deposits the minimum when credit is below threshold and wallet is sufficient', async () => { const h = harness({ credit: 0.1, wallet: 5 }) const m = new RouterAutoTopupManager({ lowThreshold: 0.5, topUpAmount: 0.5 }, h.deps) await m.tick() expect(h.deposits()).toBe(1) const fired = h.events.find(e => e.kind === 'topup-fired') expect(fired).toBeTruthy() expect(fired?.data.topUpAmount).toBe(0.5) expect(fired?.data.depositTx).toBe('0xhash1') }) it('does nothing when credit is at or above threshold', async () => { const h = harness({ credit: 1.0 }) const m = new RouterAutoTopupManager({ lowThreshold: 0.5 }, h.deps) await m.tick() expect(h.deposits()).toBe(0) expect(h.events.some(e => e.kind === 'topup-fired')).toBe(false) }) it('respects the daily cap', async () => { const h = harness({ credit: 0.1, wallet: 100 }) const m = new RouterAutoTopupManager( { lowThreshold: 0.5, topUpAmount: 0.5, maxPerDay: 2 }, h.deps, ) await m.tick() await m.tick() await m.tick() // third should be capped expect(h.deposits()).toBe(2) expect(h.events.some(e => e.kind === 'topup-failed' && e.data.reason === 'daily-cap')).toBe( true, ) }) it('refuses to deposit when the wallet is too thin (and notes cooldown)', async () => { const h = harness({ credit: 0.1, wallet: 0.3 }) const m = new RouterAutoTopupManager( { lowThreshold: 0.5, topUpAmount: 0.5, minRetainedAfterTopup: 0.1 }, h.deps, ) await m.tick() expect(h.deposits()).toBe(0) expect( h.events.some(e => e.kind === 'topup-failed' && e.data.reason === 'insufficient-wallet'), ).toBe(true) }) it('emits wallet-low once when the EOA crosses below the notify threshold', async () => { const h = harness({ credit: 1.0, wallet: 1.0 }) // credit ok → no deposit path const m = new RouterAutoTopupManager({ walletNotifyThreshold: 2.0 }, h.deps) await m.tick() expect(h.events.filter(e => e.kind === 'wallet-low')).toHaveLength(1) }) it('emits topup-skipped when the Payment Layer balance read fails', async () => { const h = harness({ credit: null }) const m = new RouterAutoTopupManager({}, h.deps) await m.tick() expect(h.deposits()).toBe(0) expect(h.events.some(e => e.kind === 'topup-skipped')).toBe(true) }) it('emits topup-failed when the deposit tx throws', async () => { const h = harness({ credit: 0.1, wallet: 5, depositImpl: async () => { throw new Error('rpc boom') }, }) const m = new RouterAutoTopupManager({ lowThreshold: 0.5 }, h.deps) await m.tick() expect(h.events.some(e => e.kind === 'topup-failed' && e.data.reason === 'tx-failed')).toBe( true, ) }) it('does not double-deposit while a deposit is in flight (overlapping ticks)', async () => { // A non-recoverable deposit is broadcast but not yet mined; a second poll // tick firing meanwhile must skip rather than deposit again on a stale read. let calls = 0 let release: (v: string) => void = () => {} const gate = new Promise(r => { release = r }) const h = harness({ credit: 0.1, wallet: 100, depositImpl: () => { calls++ return gate // first call stays pending → #depositing stays true }, }) const m = new RouterAutoTopupManager({ lowThreshold: 0.5, topUpAmount: 0.5 }, h.deps) const t1 = m.tick() const t2 = m.tick() // fires while t1's deposit is in flight → must skip release('0xhash') await Promise.all([t1, t2]) expect(calls).toBe(1) }) it('emits the daily-cap notice once, not on every tick after the cap', async () => { const h = harness({ credit: 0.1, wallet: 100 }) const m = new RouterAutoTopupManager( { lowThreshold: 0.5, topUpAmount: 0.5, maxPerDay: 1 }, h.deps, ) await m.tick() // deposit #1 await m.tick() // capped → one daily-cap event await m.tick() // capped again → must NOT emit a second expect(h.deposits()).toBe(1) expect( h.events.filter(e => e.kind === 'topup-failed' && e.data.reason === 'daily-cap'), ).toHaveLength(1) }) })