import { InFlightPaymentStateSchema } from "@aibtc/tx-schemas/core"; // ============================================================================= // Retry Decision Context // ============================================================================= /** * Structured context extracted from a payment error response body. * Callers use this to decide whether to retry, reuse, or rebuild a payment. */ export interface RetryDecisionContext { /** Canonical payment state from relay (e.g. "queued", "failed", "confirmed") */ status?: string; /** Terminal reason if payment failed (e.g. "sender_nonce_stale") */ terminalReason?: string; /** Relay-assigned payment identifier */ paymentId?: string; /** True if the relay indicates the caller should retry */ retryable?: boolean; } /** * Extract a RetryDecisionContext from a parsed (unknown) error response body. * Returns null if the body has no recognizable retry context fields. * * Handles the canonical 402 error body shape: * { status, terminalReason, paymentId, retryable, ... } */ export function getRetryDecisionContext(body: unknown): RetryDecisionContext | null { if (typeof body !== "object" || body === null || Array.isArray(body)) { return null; } const record = body as Record; // Require at least status or paymentId to be present for a meaningful context if (typeof record.status !== "string" && typeof record.paymentId !== "string") { return null; } const ctx: RetryDecisionContext = {}; if (typeof record.status === "string") ctx.status = record.status; if (typeof record.terminalReason === "string") ctx.terminalReason = record.terminalReason; if (typeof record.paymentId === "string") ctx.paymentId = record.paymentId; if (typeof record.retryable === "boolean") ctx.retryable = record.retryable; return ctx; } export function isInFlightPaymentState( status: string | undefined ): status is "queued" | "broadcasting" | "mempool" { const parsed = InFlightPaymentStateSchema.safeParse(status); return parsed.success; } export function isSenderRebuildTerminalReason(reason: string | undefined): boolean { return ( reason === "sender_nonce_stale" || reason === "sender_nonce_gap" || reason === "sender_nonce_duplicate" ); } export function isRelayRetryableTerminalReason(reason: string | undefined): boolean { return ( reason === "queue_unavailable" || reason === "sponsor_failure" || reason === "broadcast_failure" || reason === "chain_abort" || reason === "internal_error" ); }