import type { Asset, Chain } from './types'; import { toleranceFloor } from './amounts'; export type InvoiceStatus = | 'created' | 'detected' | 'confirming' | 'paid' | 'expired' | 'underpaid' | 'overpaid_resolved'; export type PaymentStatus = 'mempool' | 'confirming' | 'confirmed' | 'orphaned'; export interface OptionSnapshot { id: string; chain: Chain; asset: Asset; /** Pegged crypto amount in base units. */ requiredAmount: bigint; /** Epoch ms; used for first-option-wins ordering. */ createdAt: number; } export interface PaymentSnapshot { txid: string; optionId: string; amount: bigint; confirmations: number; status: PaymentStatus; } export interface InvoiceSnapshot { id: string; status: InvoiceStatus; toleranceBp: number; /** Epoch ms. */ expiresAt: number; paidViaOptionId: string | null; overpaidAmount: bigint | null; options: OptionSnapshot[]; payments: PaymentSnapshot[]; } export type EngineEvent = | { type: 'invoice.detected'; invoiceId: string; optionId: string; txid: string } | { type: 'invoice.confirming'; invoiceId: string; optionId: string; txid: string; confirmations: number } | { type: 'invoice.paid'; invoiceId: string; optionId: string; amountConfirmed: bigint } | { type: 'invoice.overpaid'; invoiceId: string; optionId: string; surplus: bigint } | { type: 'invoice.expired'; invoiceId: string } | { type: 'invoice.underpaid'; invoiceId: string; amountConfirmed: bigint } | { type: 'payment.late'; invoiceId: string; optionId: string; txid: string; amount: bigint } /** Internal (not a webhook): a chain reorg removed the payment that had satisfied the invoice. */ | { type: 'invoice.reverted'; invoiceId: string; reason: 'reorg' }; /** Event types that map 1:1 to outbound webhooks (Section 8 of the spec). */ export const WEBHOOK_EVENT_TYPES: ReadonlySet = new Set([ 'invoice.detected', 'invoice.confirming', 'invoice.paid', 'invoice.expired', 'invoice.underpaid', 'invoice.overpaid', 'payment.late', ]); export interface EngineConfig { confirmationThreshold: Record; } export const DEFAULT_CONFIRMATIONS: Record = { base: 30, tron: 19 }; export type Observation = | { kind: 'payment'; txid: string; optionId: string; amount: bigint; confirmations: number; inclusion: 'mempool' | 'included' | 'orphaned'; /** Epoch ms at which this observation is processed. */ at: number; } | { kind: 'tick'; at: number }; const ACTIVE_STATUSES: ReadonlySet = new Set(['created', 'detected', 'confirming']); export function isActiveStatus(status: InvoiceStatus): boolean { return ACTIVE_STATUSES.has(status); } export function isTerminalStatus(status: InvoiceStatus): boolean { return !ACTIVE_STATUSES.has(status); } export interface ReduceResult { invoice: InvoiceSnapshot; events: EngineEvent[]; } /** * The invoice state machine. Pure function: takes the current invoice aggregate and one * observation (an on-chain payment sighting or a clock tick), returns the new aggregate * plus the events it produced. Watchers and the API contain NO transition logic — they * only feed observations in and persist what comes out. */ export function reduce( invoice: InvoiceSnapshot, observation: Observation, config: EngineConfig = { confirmationThreshold: DEFAULT_CONFIRMATIONS }, ): ReduceResult { const inv: InvoiceSnapshot = { ...invoice, options: invoice.options.map((o) => ({ ...o })), payments: invoice.payments.map((p) => ({ ...p })), }; const events: EngineEvent[] = []; if (observation.kind === 'payment') { applyPayment(inv, observation, config, events); } evaluate(inv, observation.at, events); return { invoice: inv, events }; } function applyPayment( inv: InvoiceSnapshot, obs: Extract, config: EngineConfig, events: EngineEvent[], ): void { const option = inv.options.find((o) => o.id === obs.optionId); if (!option) { throw new Error(`observation references unknown option ${obs.optionId} on invoice ${inv.id}`); } const threshold = config.confirmationThreshold[option.chain]; const status: PaymentStatus = obs.inclusion === 'orphaned' ? 'orphaned' : obs.inclusion === 'mempool' ? 'mempool' : obs.confirmations >= threshold ? 'confirmed' : 'confirming'; const existing = inv.payments.find((p) => p.txid === obs.txid); if (existing) { const becameOrphaned = existing.status !== 'orphaned' && status === 'orphaned'; existing.amount = obs.amount; existing.confirmations = obs.confirmations; existing.status = status; if (becameOrphaned && inv.status === 'paid') { revertPaidIfNeeded(inv, events); } } else { inv.payments.push({ txid: obs.txid, optionId: obs.optionId, amount: obs.amount, confirmations: obs.confirmations, status, }); // Funds arriving on an invoice that already reached a terminal state (expired, paid via // another option, underpaid) are never silently dropped: recorded above + late event. if (isTerminalStatus(inv.status) && status !== 'orphaned') { events.push({ type: 'payment.late', invoiceId: inv.id, optionId: obs.optionId, txid: obs.txid, amount: obs.amount, }); } } } /** Called when a reorg orphans a payment on a paid invoice: roll back if the threshold no longer holds. */ function revertPaidIfNeeded(inv: InvoiceSnapshot, events: EngineEvent[]): void { const optionId = inv.paidViaOptionId; if (!optionId) return; const option = inv.options.find((o) => o.id === optionId); if (!option) return; const confirmed = confirmedTotal(inv, optionId); if (confirmed >= toleranceFloor(option.requiredAmount, inv.toleranceBp)) { // Still satisfied by the remaining payments; just recompute any surplus. inv.overpaidAmount = confirmed > option.requiredAmount ? confirmed - option.requiredAmount : null; return; } inv.paidViaOptionId = null; inv.overpaidAmount = null; inv.status = activeStatusFromPayments(inv); events.push({ type: 'invoice.reverted', invoiceId: inv.id, reason: 'reorg' }); } function activeStatusFromPayments(inv: InvoiceSnapshot): InvoiceStatus { if (inv.payments.some((p) => p.status === 'confirming' || p.status === 'confirmed')) return 'confirming'; if (inv.payments.some((p) => p.status === 'mempool')) return 'detected'; return 'created'; } function confirmedTotal(inv: InvoiceSnapshot, optionId: string): bigint { return inv.payments .filter((p) => p.optionId === optionId && p.status === 'confirmed') .reduce((sum, p) => sum + p.amount, 0n); } function evaluate(inv: InvoiceSnapshot, now: number, events: EngineEvent[]): void { if (isTerminalStatus(inv.status)) return; // 1. Paid check — the first option (by creation order) whose confirmed total reaches // its tolerance floor wins the invoice. const optionsInOrder = [...inv.options].sort((a, b) => a.createdAt - b.createdAt); for (const option of optionsInOrder) { const confirmed = confirmedTotal(inv, option.id); if (confirmed > 0n && confirmed >= toleranceFloor(option.requiredAmount, inv.toleranceBp)) { inv.status = 'paid'; inv.paidViaOptionId = option.id; events.push({ type: 'invoice.paid', invoiceId: inv.id, optionId: option.id, amountConfirmed: confirmed, }); if (confirmed > option.requiredAmount) { inv.overpaidAmount = confirmed - option.requiredAmount; events.push({ type: 'invoice.overpaid', invoiceId: inv.id, optionId: option.id, surplus: inv.overpaidAmount, }); } return; } } // 2. Escalate created → detected → confirming (or silently demote after a reorg). const target = activeStatusFromPayments(inv); if (target !== inv.status) { const rank: Record = { created: 0, detected: 1, confirming: 2 }; const from = rank[inv.status] ?? 0; const to = rank[target] ?? 0; if (to > from) { if (from < 1 && to >= 1) { const p = inv.payments.find((x) => x.status !== 'orphaned'); if (p) events.push({ type: 'invoice.detected', invoiceId: inv.id, optionId: p.optionId, txid: p.txid }); } if (to === 2) { const p = inv.payments.find((x) => x.status === 'confirming' || x.status === 'confirmed'); if (p) { events.push({ type: 'invoice.confirming', invoiceId: inv.id, optionId: p.optionId, txid: p.txid, confirmations: p.confirmations, }); } } } inv.status = target; } // 3. Expiry resolution. A payment observed in the same tick as expiry wins: as long as // any payment is still pending (mempool or confirming), the invoice never expires // under it — it resolves to paid or underpaid once the payment settles. if (now >= inv.expiresAt) { const pending = inv.payments.some((p) => p.status === 'mempool' || p.status === 'confirming'); if (pending) return; const totalConfirmed = inv.payments .filter((p) => p.status === 'confirmed') .reduce((sum, p) => sum + p.amount, 0n); if (totalConfirmed > 0n) { inv.status = 'underpaid'; events.push({ type: 'invoice.underpaid', invoiceId: inv.id, amountConfirmed: totalConfirmed }); } else { inv.status = 'expired'; events.push({ type: 'invoice.expired', invoiceId: inv.id }); } } }