import test from 'brittle'
import { CreditManager } from 'p2p-hiverelay/incentive/credits/index.js'
import { PricingEngine } from 'p2p-hiverelay/incentive/credits/pricing.js'
import { InvoiceManager } from 'p2p-hiverelay/incentive/credits/invoice.js'
import { MockProvider } from 'p2p-hiverelay/incentive/payment/mock-provider.js'
import { FreeTierManager } from 'p2p-hiverelay/incentive/free-tier/index.js'
import { ServiceMeter } from 'p2p-hiverelay/incentive/metering/index.js'
// ──────────────────────────────────────────────
// CreditManager Tests
// ──────────────────────────────────────────────
test('CreditManager — creates wallet on first access', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
const wallet = cm.getOrCreateWallet('app-1')
t.is(wallet.balance, 0)
t.is(wallet.appPubkey, 'app-1')
t.ok(wallet.createdAt > 0)
})
test('CreditManager — top-up adds credits', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
const tx = cm.topUp('app-1', 5000)
t.is(tx.amount, 5000)
t.is(tx.type, 'deposit')
t.is(cm.getBalance('app-1'), 5000)
})
test('CreditManager — top-up with volume bonus', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
// 10k sats → 5% bonus = 500 sats bonus
const tx = cm.topUp('app-1', 10_000)
t.is(tx.amount, 10_000)
t.is(tx.bonus, 500)
t.is(tx.totalCredit, 10_500)
t.is(cm.getBalance('app-1'), 10_500)
})
test('CreditManager — large deposit bonus tiers', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
// 100k sats → 10% bonus
const tx1 = cm.topUp('app-1', 100_000)
t.is(tx1.bonus, 10_000)
// 1M sats → 15% bonus
const tx2 = cm.topUp('app-2', 1_000_000)
t.is(tx2.bonus, 150_000)
})
test('CreditManager — deduct subtracts from balance', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 1000)
const result = cm.deduct('app-1', 100, 'ai.infer')
t.is(result.success, true)
t.is(result.cost, 100)
t.is(result.balance, 900)
t.is(cm.getBalance('app-1'), 900)
})
test('CreditManager — deduct fails on insufficient credits', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 200)
const result = cm.deduct('app-1', 500, 'ai.infer')
t.is(result.success, false)
t.is(result.reason, 'INSUFFICIENT_CREDITS')
// Balance unchanged
t.is(cm.getBalance('app-1'), 200)
})
test('CreditManager — deduct fails on no wallet', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
const result = cm.deduct('unknown-app', 10, 'ai.infer')
t.is(result.success, false)
t.is(result.reason, 'NO_WALLET')
})
test('CreditManager — min top-up enforced', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
try {
cm.topUp('app-1', 10) // Below 100 sat minimum
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('MIN_TOPUP'))
}
})
test('CreditManager — max balance enforced', async (t) => {
const cm = new CreditManager({ maxBalance: 10_000, welcomeCredits: 0 })
cm.topUp('app-1', 8000)
try {
cm.topUp('app-1', 5000)
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('MAX_BALANCE'))
}
})
test('CreditManager — freeze blocks deductions', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 5000)
cm.freezeWallet('app-1', 'suspected abuse')
const result = cm.deduct('app-1', 100, 'ai.infer')
t.is(result.success, false)
t.is(result.reason, 'WALLET_FROZEN')
})
test('CreditManager — freeze blocks top-ups', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 5000)
cm.freezeWallet('app-1', 'suspected abuse')
try {
cm.topUp('app-1', 1000)
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('WALLET_FROZEN'))
}
})
test('CreditManager — unfreeze restores access', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 5000)
cm.freezeWallet('app-1', 'test')
cm.unfreezeWallet('app-1')
const result = cm.deduct('app-1', 100, 'ai.infer')
t.is(result.success, true)
})
test('CreditManager — canAfford check', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 500)
t.is(cm.canAfford('app-1', 500), true)
t.is(cm.canAfford('app-1', 501), false)
t.is(cm.canAfford('unknown', 1), false)
})
test('CreditManager — transaction history', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 5000)
cm.deduct('app-1', 100, 'ai.infer')
cm.deduct('app-1', 50, 'ai.embed')
const { transactions, total } = cm.getTransactions('app-1')
t.is(total, 3) // 1 deposit + 2 deductions
// Newest first
t.is(transactions[0].type, 'deduction')
t.is(transactions[0].route, 'ai.embed')
})
test('CreditManager — wallet summary', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 5000)
cm.deduct('app-1', 200, 'ai.infer')
const wallet = cm.getWallet('app-1')
t.is(wallet.balance, 4800)
t.is(wallet.totalDeposited, 5000)
t.is(wallet.totalSpent, 200)
t.is(wallet.frozen, false)
})
test('CreditManager — stats aggregation', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 5000)
cm.topUp('app-2', 3000)
cm.deduct('app-1', 200, 'ai.infer')
const stats = cm.stats()
t.is(stats.totalWallets, 2)
t.is(stats.totalDeposited, 8000)
t.is(stats.totalSpent, 200)
t.is(stats.totalBalance, 7800)
})
test('CreditManager — events emitted', async (t) => {
t.plan(4)
const cm = new CreditManager({ welcomeCredits: 0 })
cm.on('wallet-created', () => t.pass('wallet-created'))
cm.on('credit-added', (data) => {
t.is(data.amount, 1000)
t.is(data.balance, 1000)
})
cm.on('credit-deducted', () => t.pass('credit-deducted'))
cm.topUp('app-1', 1000)
cm.deduct('app-1', 10, 'ai.infer')
})
test('CreditManager — save and load', async (t) => {
const path = '/tmp/hiverelay-test-credits-' + Date.now() + '.json'
const cm1 = new CreditManager({ storagePath: path, welcomeCredits: 0 })
cm1.topUp('app-1', 5000)
cm1.deduct('app-1', 100, 'ai.infer')
await cm1.save()
const cm2 = new CreditManager({ storagePath: path, welcomeCredits: 0 })
await cm2.load()
t.is(cm2.getBalance('app-1'), 4900)
t.is(cm2.getWallet('app-1').totalSpent, 100)
})
// ──────────────────────────────────────────────
// Welcome Credits & Grant Tests
// ──────────────────────────────────────────────
test('CreditManager — welcome credits on new wallet', async (t) => {
const cm = new CreditManager({ welcomeCredits: 1000 })
const wallet = cm.getOrCreateWallet('app-1')
t.is(wallet.balance, 1000)
t.is(wallet.welcomeCreditsReceived, 1000)
t.is(wallet.transactions.length, 1)
t.is(wallet.transactions[0].type, 'welcome')
t.is(wallet.transactions[0].amount, 1000)
})
test('CreditManager — welcome credits default is 1000', async (t) => {
const cm = new CreditManager()
const wallet = cm.getOrCreateWallet('app-1')
t.is(wallet.balance, 1000)
t.is(cm.welcomeCredits, 1000)
})
test('CreditManager — welcome credits can be zero', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
const wallet = cm.getOrCreateWallet('app-1')
t.is(wallet.balance, 0)
t.is(wallet.welcomeCreditsReceived, 0)
t.is(wallet.transactions.length, 0)
})
test('CreditManager — welcome credits only on first access', async (t) => {
const cm = new CreditManager({ welcomeCredits: 500 })
cm.getOrCreateWallet('app-1')
cm.getOrCreateWallet('app-1') // second call
t.is(cm.getBalance('app-1'), 500) // not doubled
})
test('CreditManager — grantCredits adds free credits', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
const tx = cm.grantCredits('app-1', 5000, 'Dev request')
t.is(tx.type, 'grant')
t.is(tx.amount, 5000)
t.is(tx.reason, 'Dev request')
t.is(cm.getBalance('app-1'), 5000)
})
test('CreditManager — grantCredits emits event', async (t) => {
t.plan(3)
const cm = new CreditManager({ welcomeCredits: 0 })
cm.on('credits-granted', (data) => {
t.is(data.app, 'app-1')
t.is(data.amount, 2000)
t.is(data.reason, 'testing')
})
cm.grantCredits('app-1', 2000, 'testing')
})
test('CreditManager — grantCredits rejects frozen wallet', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 1000)
cm.freezeWallet('app-1', 'abuse')
try {
cm.grantCredits('app-1', 500, 'grant')
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('WALLET_FROZEN'))
}
})
test('CreditManager — stats includes welcome credits', async (t) => {
const cm = new CreditManager({ welcomeCredits: 500 })
cm.getOrCreateWallet('app-1')
cm.getOrCreateWallet('app-2')
const stats = cm.stats()
t.is(stats.totalWelcomeCredits, 1000)
t.is(stats.welcomeCreditsPerWallet, 500)
t.is(stats.totalBalance, 1000)
})
// ──────────────────────────────────────────────
// PricingEngine Tests
// ──────────────────────────────────────────────
test('PricingEngine — free routes cost zero', async (t) => {
const pe = new PricingEngine()
t.is(pe.isFree('ai.list-models'), true)
t.is(pe.isFree('identity.verify'), true)
t.is(pe.isFree('schema.validate'), true)
const result = pe.calculate('ai.list-models')
t.is(result.cost, 0)
})
test('PricingEngine — AI inference token-based pricing', async (t) => {
const pe = new PricingEngine()
// 1000 input tokens, 500 output tokens
const result = pe.calculate('ai.infer', { inputTokens: 1000, outputTokens: 500 })
// base: 1 sat + input: 1000 * 0.001 = 1 sat + output: 500 * 0.002 = 1 sat = 3 sats
t.is(result.cost, 3)
t.ok(result.breakdown.base)
t.ok(result.breakdown.inputTokens)
t.ok(result.breakdown.outputTokens)
})
test('PricingEngine — AI inference minimum per-call cost', async (t) => {
const pe = new PricingEngine()
// Even with 0 tokens, base cost is 1 sat
const result = pe.calculate('ai.infer', {})
t.is(result.cost, 1)
})
test('PricingEngine — embeddings pricing', async (t) => {
const pe = new PricingEngine()
const result = pe.calculate('ai.embed', { inputTokens: 2000 })
// base: 1 (ceil of 0.5) + input: 2000 * 0.0005 = 1 sat = ceil(1.5) = 2
t.is(result.cost, 2)
})
test('PricingEngine — storage write with bytes', async (t) => {
const pe = new PricingEngine()
// Write 10KB
const result = pe.calculate('storage.drive-write', { bytes: 10240 })
// base: 1 sat + bytes: 10 * 0.01 = 0.1 sat = ceil(1.1) = 2
t.is(result.cost, 2)
})
test('PricingEngine — operator margin multiplier', async (t) => {
const pe = new PricingEngine({ margin: 1.5 })
const result = pe.calculate('ai.infer', { inputTokens: 1000, outputTokens: 500 })
// Normal: 3 sats, with 1.5x margin: ceil(4.5) = 5
t.is(result.cost, 5)
})
test('PricingEngine — rate card returns all routes', async (t) => {
const pe = new PricingEngine()
const card = pe.getRateCard()
t.ok(card['ai.infer'])
t.ok(card['ai.embed'])
t.ok(card['storage.drive-write'])
t.ok(card['identity.sign'])
})
test('PricingEngine — comparison generates valid output', async (t) => {
const pe = new PricingEngine()
const comp = pe.getComparison(60000)
t.is(comp.btcPriceUsd, 60000)
t.ok(comp.services['ai.infer'])
t.ok(comp.services['ai.infer'].hiverelay)
t.ok(comp.services['ai.infer'].claude)
t.ok(comp.services['ai.infer'].openai)
})
test('PricingEngine — batch estimation', async (t) => {
const pe = new PricingEngine()
const estimate = pe.estimate([
{ route: 'ai.infer', meta: { inputTokens: 1000, outputTokens: 500 } },
{ route: 'ai.embed', meta: { inputTokens: 500 } },
{ route: 'storage.drive-write', meta: { bytes: 1024 } }
])
t.ok(estimate.total > 0)
t.is(estimate.items.length, 3)
})
test('PricingEngine — unknown route charges 1 sat', async (t) => {
const pe = new PricingEngine()
const result = pe.calculate('custom.unknown-route')
t.is(result.cost, 1)
})
test('PricingEngine — custom rate overrides', async (t) => {
const pe = new PricingEngine({
rates: {
'ai.infer': { perCall: 5 } // Override base cost
}
})
const result = pe.calculate('ai.infer', {})
t.is(result.cost, 5)
})
// ──────────────────────────────────────────────
// InvoiceManager Tests
// ──────────────────────────────────────────────
test('InvoiceManager — create invoice', async (t) => {
const provider = new MockProvider()
await provider.connect()
const cm = new CreditManager({ welcomeCredits: 0 })
const im = new InvoiceManager({ provider, creditManager: cm })
const inv = await im.createInvoice('app-1', 10_000)
t.ok(inv.id.startsWith('inv_'))
t.ok(inv.bolt11.startsWith('lnbc'))
t.is(inv.amount, 10_000)
t.is(inv.status, 'pending')
t.ok(inv.expiresAt > Date.now())
im.stop()
})
test('InvoiceManager — settle invoice credits wallet', async (t) => {
const provider = new MockProvider()
await provider.connect()
const cm = new CreditManager({ welcomeCredits: 0 })
const im = new InvoiceManager({ provider, creditManager: cm })
const inv = await im.createInvoice('app-1', 10_000)
const result = await im.settleInvoice(inv.id)
t.is(result.status, 'settled')
t.ok(result.creditTx)
t.is(cm.getBalance('app-1'), 10_500) // 10k + 5% bonus
im.stop()
})
test('InvoiceManager — double settle is idempotent', async (t) => {
const provider = new MockProvider()
await provider.connect()
const cm = new CreditManager({ welcomeCredits: 0 })
const im = new InvoiceManager({ provider, creditManager: cm })
const inv = await im.createInvoice('app-1', 5000)
await im.settleInvoice(inv.id)
const result2 = await im.settleInvoice(inv.id)
t.is(result2.alreadySettled, true)
t.is(cm.getBalance('app-1'), 5000) // No double credit
im.stop()
})
test('InvoiceManager — cancel invoice', async (t) => {
const provider = new MockProvider()
await provider.connect()
const im = new InvoiceManager({ provider })
const inv = await im.createInvoice('app-1', 5000)
const result = im.cancelInvoice(inv.id)
t.is(result.status, 'cancelled')
// Cannot settle cancelled invoice
try {
await im.settleInvoice(inv.id)
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('NOT_PENDING'))
}
im.stop()
})
test('InvoiceManager — expired invoices', async (t) => {
const provider = new MockProvider()
await provider.connect()
const im = new InvoiceManager({ provider, expiryMs: 1 }) // 1ms expiry
const inv = await im.createInvoice('app-1', 5000)
// Wait for expiry
await new Promise(resolve => setTimeout(resolve, 10))
im._expirePending()
const status = im.getInvoice(inv.id)
t.is(status.status, 'expired')
im.stop()
})
test('InvoiceManager — minimum invoice amount', async (t) => {
const provider = new MockProvider()
await provider.connect()
const im = new InvoiceManager({ provider })
try {
await im.createInvoice('app-1', 50) // Below 100 sat minimum
t.fail('Should have thrown')
} catch (err) {
t.ok(err.message.includes('MIN_AMOUNT'))
}
im.stop()
})
test('InvoiceManager — get app invoices', async (t) => {
const provider = new MockProvider()
await provider.connect()
const im = new InvoiceManager({ provider })
await im.createInvoice('app-1', 1000)
await im.createInvoice('app-1', 2000)
await im.createInvoice('app-2', 3000)
const app1Invoices = im.getAppInvoices('app-1')
t.is(app1Invoices.length, 2)
const app2Invoices = im.getAppInvoices('app-2')
t.is(app2Invoices.length, 1)
im.stop()
})
test('InvoiceManager — stats', async (t) => {
const provider = new MockProvider()
await provider.connect()
const cm = new CreditManager({ welcomeCredits: 0 })
const im = new InvoiceManager({ provider, creditManager: cm })
await im.createInvoice('app-1', 5000)
const inv2 = await im.createInvoice('app-1', 10_000)
await im.settleInvoice(inv2.id)
const stats = im.stats()
t.is(stats.total, 2)
t.is(stats.pending, 1)
t.is(stats.settled, 1)
t.is(stats.totalSettledSats, 10_000)
im.stop()
})
test('InvoiceManager — events', async (t) => {
t.plan(2)
const provider = new MockProvider()
await provider.connect()
const cm = new CreditManager({ welcomeCredits: 0 })
const im = new InvoiceManager({ provider, creditManager: cm })
im.on('invoice-created', (data) => t.ok(data.id))
im.on('invoice-settled', (data) => t.is(data.amount, 5000))
const inv = await im.createInvoice('app-1', 5000)
await im.settleInvoice(inv.id)
im.stop()
})
// ──────────────────────────────────────────────
// Integration: Credits + FreeTier + Metering
// ──────────────────────────────────────────────
test('Integration — apps with credits auto-promote to standard tier', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('paid-app', 10_000)
const ft = new FreeTierManager({ creditManager: cm })
// paid-app should be standard (has credits)
t.is(ft.getTier('paid-app'), 'standard')
// unknown app should be free
t.is(ft.getTier('free-app'), 'free')
})
test('Integration — app falls to free tier when credits depleted', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 200)
const ft = new FreeTierManager({ creditManager: cm })
t.is(ft.getTier('app-1'), 'standard')
// Deplete credits
cm.deduct('app-1', 200, 'ai.infer')
t.is(cm.getBalance('app-1'), 0)
t.is(ft.getTier('app-1'), 'free')
})
test('Integration — full credit purchase flow', async (t) => {
// 1. Create provider + credit manager + invoice manager
const provider = new MockProvider()
await provider.connect()
const cm = new CreditManager({ welcomeCredits: 0 })
const im = new InvoiceManager({ provider, creditManager: cm })
// 2. App requests invoice to buy credits
const invoice = await im.createInvoice('app-1', 100_000)
t.ok(invoice.bolt11)
t.is(cm.getBalance('app-1'), 0) // Not credited yet
// 3. App pays invoice externally, settlement detected
await im.settleInvoice(invoice.id)
// 4. Credits in wallet (100k + 10% bonus = 110k)
t.is(cm.getBalance('app-1'), 110_000)
// 5. App makes service calls, credits deducted
const pricing = new PricingEngine()
const price = pricing.calculate('ai.infer', { inputTokens: 2000, outputTokens: 1000 })
const deduction = cm.deduct('app-1', price.cost, 'ai.infer')
t.is(deduction.success, true)
t.ok(cm.getBalance('app-1') < 110_000)
im.stop()
})
test('Integration — metering + pricing + credits end-to-end', async (t) => {
const cm = new CreditManager({ welcomeCredits: 0 })
cm.topUp('app-1', 50_000)
const meter = new ServiceMeter()
const pricing = new PricingEngine()
const ft = new FreeTierManager({ creditManager: cm })
// Simulate router middleware for 10 AI inference calls
for (let i = 0; i < 10; i++) {
const appKey = 'app-1'
const route = 'ai.infer'
const meta = { inputTokens: 500, outputTokens: 200 }
// Check quota
const quota = ft.check(appKey, route, meter)
t.is(quota.allowed, true)
// Calculate cost
const price = pricing.calculate(route, meta)
t.ok(price.cost > 0)
// Deduct credits
const deduction = cm.deduct(appKey, price.cost, route)
t.is(deduction.success, true)
// Record usage
meter.record(appKey, route)
}
const usage = meter.getUsage('app-1')
t.is(usage.totalCalls, 10)
// Started with 1k welcome + 50k deposit + 5% bonus = 53,500. Should have spent some on 10 calls.
t.ok(cm.getBalance('app-1') < 53_500)
t.ok(cm.getBalance('app-1') > 0)
})
// ──────────────────────────────────────────────
// MockProvider enhancements
// ──────────────────────────────────────────────
test('MockProvider — lookupInvoice', async (t) => {
const provider = new MockProvider()
await provider.connect()
const inv = await provider.createInvoice(5000, 'test')
const lookup1 = await provider.lookupInvoice(inv.rHash)
t.is(lookup1.settled, false)
provider.settleInvoice(inv.rHash)
const lookup2 = await provider.lookupInvoice(inv.rHash)
t.is(lookup2.settled, true)
})
test('MockProvider — lookupInvoice unknown hash', async (t) => {
const provider = new MockProvider()
await provider.connect()
const result = await provider.lookupInvoice('nonexistent')
t.is(result, null)
})