From ab5f41e53ee8b67f38d0a5691343c92317bdc14f Mon Sep 17 00:00:00 2001 From: Aishat004 Date: Thu, 27 Aug 2026 11:49:54 +0000 Subject: [PATCH] feat: all issues on vault issues fixed --- .github/workflows/backend-governance.yml | 3 + backend/LATENCY_MONITORING.md | 17 ++++ backend/docs/PRISMA_MIGRATIONS.md | 37 +++++++++ backend/monitoring/prometheus-alerts.yml | 67 +++++++++++++++ backend/package.json | 1 + backend/src/healthProbe.ts | 3 + backend/src/index.ts | 7 ++ backend/src/metrics.ts | 37 +++++++++ frontend/src/components/VaultDashboard.tsx | 41 +++++++--- .../src/components/VaultPositionBalance.tsx | 82 +++++++++++++++++++ frontend/src/hooks/useWalletConnection.ts | 5 +- frontend/src/i18n/locales/en.ts | 23 ++++++ frontend/src/i18n/locales/es.ts | 23 ++++++ frontend/src/lib/errorMappers.ts | 57 +++++++++++++ 14 files changed, 389 insertions(+), 14 deletions(-) create mode 100644 backend/monitoring/prometheus-alerts.yml create mode 100644 frontend/src/components/VaultPositionBalance.tsx diff --git a/.github/workflows/backend-governance.yml b/.github/workflows/backend-governance.yml index 58567cd6..7f015701 100644 --- a/.github/workflows/backend-governance.yml +++ b/.github/workflows/backend-governance.yml @@ -56,6 +56,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Validate Prisma schema and migration consistency + run: npm run prisma:schema-check + - name: Apply PostgreSQL migrations run: npm run db:migrate diff --git a/backend/LATENCY_MONITORING.md b/backend/LATENCY_MONITORING.md index 7fe211b5..32948741 100644 --- a/backend/LATENCY_MONITORING.md +++ b/backend/LATENCY_MONITORING.md @@ -13,6 +13,8 @@ The latency monitoring system tracks P95 latency for all API endpoints and sends - **SLO breach detection** with automatic alerting - **Multiple alert integrations** (Slack, PagerDuty) - **Configurable thresholds** via environment variables +- **Prometheus request error metrics** for error-budget burn alerts +- **External dependency latency and failure metrics** from health probes - **Endpoint normalization** for dynamic routes - **Admin endpoints** for monitoring status @@ -100,6 +102,21 @@ Service: YieldVault Backend ## Monitoring Endpoints +Prometheus alert rules are versioned in +`backend/monitoring/prometheus-alerts.yml`. Load this file from the Prometheus +configuration used by the deployment. It alerts when critical endpoint P95 +latency exceeds the endpoint registry budget, when 5xx traffic burns more than +1% of the critical endpoint error budget over five minutes, or when an external +dependency probe has a P95 latency above two seconds. + +The `/metrics` endpoint exposes: + +- `http_request_error_total`, labeled by method, normalized route, status code, + and status class. +- `external_dependency_latency_seconds`, labeled by dependency, operation, and + success or failure outcome. +- `external_dependency_error_total`, labeled by dependency and operation. + ### Admin Endpoints (API Key Required) #### GET `/admin/latency-status` diff --git a/backend/docs/PRISMA_MIGRATIONS.md b/backend/docs/PRISMA_MIGRATIONS.md index 28750ecd..98913615 100644 --- a/backend/docs/PRISMA_MIGRATIONS.md +++ b/backend/docs/PRISMA_MIGRATIONS.md @@ -26,6 +26,7 @@ Run from `backend/`: | Script | Purpose | | --- | --- | | `npm run prisma:generate` | Regenerate the Prisma client | +| `npm run prisma:schema-check` | Validate the schema and fail if committed migrations do not produce it | | `npm run prisma:migrate -- --name add_foo` | Create a migration from `schema.prisma` (dev) | | `npm run prisma:deploy` | Apply pending migrations (CI / prod) | | `npm run prisma:status` | Show applied vs pending | @@ -41,6 +42,15 @@ Create a change: change is reversible. 5. Commit schema, migration, and rollback together. +The backend governance workflow runs `prisma:schema-check` on every pull +request that changes the backend or migration workflow. The check validates +`schema.prisma`, then compares the final state represented by +`prisma/migrations` with the schema datamodel. A non-zero result means the +schema was changed without a matching migration, a migration was edited after +being applied, or a migration folder is incomplete. Do not bypass this check +by editing generated or deployed database state; create and commit the proper +forward migration instead. + Existing schema is already captured by `0_init` plus follow-up migrations. New environments run `prisma:deploy` (SQLite/dev) or `db:migrate` (Postgres). @@ -73,3 +83,30 @@ Prisma does not generate automatic down migrations. Rollback is explicit: Never `DROP` or `TRUNCATE` in a canary window. Follow `docs/CANARY_MIGRATION_STRATEGY.md`. + +## Drift recovery + +When CI reports schema drift: + +1. Run `npm run prisma:schema-check` from `backend/` and inspect the diff. +2. If `schema.prisma` is ahead, create a named migration with + `npm run prisma:migrate -- --name ` and commit its + `migration.sql`. +3. If a committed migration was edited after deployment, restore its original + SQL and create a new corrective migration. Never rewrite an applied + migration to make the check pass. +4. Check the target with `npm run prisma:status`. For a failed deployment, + resolve the migration only after confirming whether any SQL was applied. +5. Use the documented rollback SQL or restore a verified backup for destructive + changes, then deploy the corrective forward migration and rerun the checks. + +For an unapplied migration, use: + +```bash +npx prisma migrate resolve --schema prisma/schema.prisma --rolled-back +``` + +For an applied migration with a rollback file, use +`npm run prisma:rollback -- --name ` and follow the script output. Take a +backup and coordinate production recovery before running destructive rollback +SQL. diff --git a/backend/monitoring/prometheus-alerts.yml b/backend/monitoring/prometheus-alerts.yml new file mode 100644 index 00000000..15b871e9 --- /dev/null +++ b/backend/monitoring/prometheus-alerts.yml @@ -0,0 +1,67 @@ +groups: + - name: yieldvault-backend-slos + interval: 30s + rules: + - alert: YieldVaultHealthLatencyHigh + expr: histogram_quantile(0.95, sum by (route, method, le) (rate(http_response_time_seconds_bucket{route="/health"}[5m]))) > 0.05 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault health endpoint latency budget exceeded" + description: "The /health P95 latency is above its 50ms budget." + + - alert: YieldVaultReadinessLatencyHigh + expr: histogram_quantile(0.95, sum by (route, method, le) (rate(http_response_time_seconds_bucket{route="/ready"}[5m]))) > 0.1 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault readiness latency budget exceeded" + description: "The /ready P95 latency is above its 100ms budget." + + - alert: YieldVaultCriticalReadLatencyHigh + expr: histogram_quantile(0.95, sum by (route, method, le) (rate(http_response_time_seconds_bucket{route="/api/v1/vault/summary"}[5m]))) > 0.2 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault summary latency budget exceeded" + description: "The vault summary P95 latency is above its 200ms budget." + + - alert: YieldVaultCriticalWriteLatencyHigh + expr: histogram_quantile(0.95, sum by (route, method, le) (rate(http_response_time_seconds_bucket{route=~"/api/v1/vault/(deposit|withdraw)"}[5m]))) > 0.5 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault write latency budget exceeded" + description: "A critical vault write endpoint has P95 latency above its 500ms budget." + + - alert: YieldVaultCriticalEndpointErrorBudgetBurn + expr: | + sum by (route) (rate(http_request_error_total{status_class="5xx",route=~"/health|/ready|/api/v1/vault/(summary|deposit|withdraw)"}[5m])) + / + sum by (route) (rate(http_request_count{route=~"/health|/ready|/api/v1/vault/(summary|deposit|withdraw)"}[5m])) + > 0.01 + for: 5m + labels: + severity: critical + service: yieldvault-backend + annotations: + summary: "YieldVault critical endpoint error budget is burning" + description: "5xx responses exceed 1% of traffic for {{ $labels.route }} over the last 5 minutes." + + - alert: YieldVaultExternalDependencyLatencyHigh + expr: histogram_quantile(0.95, sum by (dependency, operation, le) (rate(external_dependency_latency_seconds_bucket{outcome="success"}[5m]))) > 2 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault external dependency is slow" + description: "P95 {{ $labels.dependency }} {{ $labels.operation }} latency is above 2 seconds." \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index c5f5b40c..9f11ed44 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "db:check-drift": "node scripts/check-postgres-drift.js", "adrs:check": "node scripts/check-adrs.js", "prisma:generate": "prisma generate --schema prisma/schema.prisma", + "prisma:schema-check": "prisma validate --schema prisma/schema.prisma && prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code", "prisma:migrate": "prisma migrate dev --schema prisma/schema.prisma", "prisma:deploy": "prisma migrate deploy --schema prisma/schema.prisma", "prisma:status": "prisma migrate status --schema prisma/schema.prisma", diff --git a/backend/src/healthProbe.ts b/backend/src/healthProbe.ts index 9576543a..e30a4d1b 100644 --- a/backend/src/healthProbe.ts +++ b/backend/src/healthProbe.ts @@ -1,4 +1,5 @@ import { logger } from './middleware/structuredLogging'; +import { observeExternalDependency } from './metrics'; export interface DependencyProbeState { status: 'up' | 'down' | 'degraded'; @@ -52,6 +53,7 @@ class HealthProbeService { try { const result = await registration.probe(); const latencyMs = Date.now() - startMs; + observeExternalDependency(name, 'health_probe', latencyMs, result === 'up' ? 'success' : 'failure'); state.status = result; state.latencyMs = latencyMs; @@ -66,6 +68,7 @@ class HealthProbeService { } } catch (error) { const latencyMs = Date.now() - startMs; + observeExternalDependency(name, 'health_probe', latencyMs, 'failure'); state.status = 'down'; state.latencyMs = latencyMs; state.lastCheckedAt = new Date().toISOString(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 27da7a57..1d65fcbd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -110,6 +110,7 @@ import { register, httpRequestCount, httpResponseTime, + httpRequestErrorCount, activeConnections, updateVaultMetrics, syncJobGovernanceMetrics, @@ -674,6 +675,12 @@ app.use((req: Request, res: Response, next: NextFunction) => { httpRequestCount.inc(labels); httpResponseTime.observe(labels, durationSeconds); + if (res.statusCode >= 400) { + httpRequestErrorCount.inc({ + ...labels, + status_class: `${Math.floor(res.statusCode / 100)}xx`, + }); + } if (res.statusCode === 429) { rateLimitEvents.inc({ tier: req.apiVersion ?? 'global', outcome: 'limited' }); } diff --git a/backend/src/metrics.ts b/backend/src/metrics.ts index 6d698c81..5f247b2b 100644 --- a/backend/src/metrics.ts +++ b/backend/src/metrics.ts @@ -29,6 +29,43 @@ export const httpResponseTime = new Histogram({ registers: [register], }); +export const httpRequestErrorCount = new Counter({ + name: 'http_request_error_total', + help: 'Total HTTP requests completed with a client or server error status', + labelNames: ['method', 'route', 'status_code', 'status_class'], + registers: [register], +}); + +export const externalDependencyLatency = new Histogram({ + name: 'external_dependency_latency_seconds', + help: 'Latency of external dependency probes and calls in seconds', + labelNames: ['dependency', 'operation', 'outcome'], + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 30], + registers: [register], +}); + +export const externalDependencyErrorCount = new Counter({ + name: 'external_dependency_error_total', + help: 'Total failed external dependency probes and calls', + labelNames: ['dependency', 'operation'], + registers: [register], +}); + +export function observeExternalDependency( + dependency: string, + operation: string, + durationMs: number, + outcome: 'success' | 'failure', +): void { + externalDependencyLatency.observe( + { dependency, operation, outcome }, + durationMs / 1000, + ); + if (outcome === 'failure') { + externalDependencyErrorCount.inc({ dependency, operation }); + } +} + export const activeConnections = new Gauge({ name: 'http_active_connections', help: 'Number of active HTTP connections', diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index be3593e8..72754c73 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -14,11 +14,12 @@ import { useDelayedLoading } from "../hooks/useDelayedLoading"; import { useVault } from "../context/VaultContext"; import ApiStatusBanner from "./ApiStatusBanner"; import SharePriceDisplay from "./SharePriceDisplay"; +import VaultPositionBalance from "./VaultPositionBalance"; import VaultPerformanceChart from "./VaultPerformanceChart"; import { useToast } from "../context/ToastContext"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs"; import { FormField } from "../forms"; -import { isApiError, isValidationError } from "../lib/api"; +import { isValidationError } from "../lib/api"; import { useForm } from "../forms/useForm"; import { validate, type ValidationSchema } from "../forms/validate"; import { useDepositMutation, useWithdrawMutation } from "../hooks/useVaultMutations"; @@ -26,7 +27,7 @@ import { useTokenAllowance } from "../hooks/useTokenAllowance"; import { usePortfolioHoldings } from "../hooks/usePortfolioData"; import { createDepositFormSchema, MIN_DEPOSIT_AMOUNT, MAX_DEPOSIT_AMOUNT, USDC_DISPLAY_DECIMALS } from "../forms/schemas/depositFormSchema"; import { createWithdrawFormSchema } from "../forms/schemas/withdrawFormSchema"; -import { mapServerError } from "../lib/errorMappers"; +import { mapServerError, mapTransactionError } from "../lib/errorMappers"; import confetti from "canvas-confetti"; import CopyButton from "./CopyButton"; import { Button } from "./ui/Button"; @@ -226,7 +227,7 @@ const VaultDashboard: React.FC = ({ }); const { isStale: statsIsStale, ageText: statsAgeText } = useStaleIndicator(lastUpdate); - const { data: portfolioHoldings } = usePortfolioHoldings(walletAddress); + const { data: portfolioHoldings, isLoading: isPortfolioLoading } = usePortfolioHoldings(walletAddress); // Deposit balance comes from the connected wallet's USDC balance; withdraw // balance is the user's current vault position (sum of holdings value). @@ -244,6 +245,8 @@ const VaultDashboard: React.FC = ({ txHash?: string; retryable?: boolean; actionType?: TransactionTab; + technicalCode?: string; + supportReference?: string; } | null>(null); const [retryCount, setRetryCount] = useState(0); const [mobileActionsOpen, setMobileActionsOpen] = useState(false); @@ -704,20 +707,20 @@ const VaultDashboard: React.FC = ({ dashboardUrl.setStep("amount"); } - let errorMessage = t("vaultDashboard.toast.genericError"); + const transactionError = mapTransactionError(err); + const errorMessage = t(`vaultDashboard.toast.transactionErrors.${transactionError.kind}`); - if (isValidationError(err)) { - errorMessage = err.details?.[0]?.message || errorMessage; - } else if (err instanceof Error) { - errorMessage = err.message; - } else if (mappedError.generalError) { - errorMessage = mappedError.generalError; - } + console.error("Vault transaction failed", { + action: actionType, + error: err, + technicalCode: transactionError.technicalCode, + supportReference: transactionError.supportReference, + }); // Field-level validation failures need corrected input, not a blind resubmit. // Everything else (network hiccups, RPC timeouts, transient 5xx) is worth retrying. const retryable = - !hasFieldErrors && !isValidationError(err) && (isApiError(err) ? err.retryable : true); + !hasFieldErrors && !isValidationError(err) && transactionError.retryable; if (options.isRetry) { setRetryCount((count) => count + 1); @@ -728,6 +731,8 @@ const VaultDashboard: React.FC = ({ message: errorMessage, retryable, actionType, + technicalCode: transactionError.technicalCode, + supportReference: transactionError.supportReference, }); toast.error({ @@ -865,6 +870,13 @@ const VaultDashboard: React.FC = ({ }} /> + {walletAddress && ( + holding.symbol === "yvUSDC")} + isLoading={isPortfolioLoading} + /> + )} + {/* Per-widget refresh control + stale indicator for stats panel */}
= ({

{transactionResult?.message}

+ {transactionResult?.supportReference && ( +

+ Reference: {transactionResult.supportReference} +

+ )} {!transactionResult?.success && retryCount >= MAX_TRANSACTION_RETRY_ATTEMPTS && (

= ({ holding, isLoading }) => { + const { t } = useTranslation(); + const { sharePrice, isLoading: isPriceLoading } = useSharePrice(); + const shares = holding?.shares ?? 0; + const valueUsd = holding?.valueUsd ?? 0; + const hasPosition = Boolean(holding) && shares > 0; + + return ( +

+
+
+

{t("vaultDashboard.position.title")}

+

+ {t("vaultDashboard.position.subtitle")} +

+
+ +
+ +
+
+
+ {t("vaultDashboard.position.value")} +
+
+ {isLoading ? t("vaultDashboard.position.loading") : `$${formatAmount(valueUsd, 2)} USDC`} +
+
+
+
+ {t("vaultDashboard.position.shares")} +
+
+ {isLoading ? t("vaultDashboard.position.loading") : `${formatAmount(shares, 6)} yvUSDC`} +
+
+
+ +
+ + {t("vaultDashboard.position.sharePrice")}: + {isPriceLoading && sharePrice === null ? t("vaultDashboard.position.loading") : sharePrice !== null ? `${sharePrice.toFixed(4)} USDC` : t("vaultDashboard.position.unavailable")} + + + {hasPosition ? t("vaultDashboard.position.accrual") : t("vaultDashboard.position.noPosition")} +
+
+ ); +}; + +export default VaultPositionBalance; \ No newline at end of file diff --git a/frontend/src/hooks/useWalletConnection.ts b/frontend/src/hooks/useWalletConnection.ts index 412ef29c..73caedc1 100644 --- a/frontend/src/hooks/useWalletConnection.ts +++ b/frontend/src/hooks/useWalletConnection.ts @@ -256,9 +256,10 @@ export function useWalletConnection({ } catch (e: unknown) { const error = classifyWalletConnectionError(e); dispatch({ type: "CONNECT_FAILED", error }); + const errorCopy = walletErrorI18nKeys(error.code); toast.error({ - title: t("toast.walletConnectionFailed.title"), - description: t("toast.walletConnectionFailed.description"), + title: t(errorCopy.title), + description: t(errorCopy.description), }); } }, [onConnect, toast, t]); diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 02805876..6fc01f5d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -748,12 +748,35 @@ export const en = { copyFailedDesc: "Could not copy link to clipboard.", usdcApproved: "USDC Approved", approvalFailed: "Approval Failed", + transactionErrors: { + walletRejected: "Your wallet cancelled the request. Approve it in Freighter to continue, or choose Start Over.", + walletPermission: "Freighter did not authorize this request. Unlock the wallet and approve access, then try again.", + network: "The Stellar network or wallet did not respond. Check your connection and try again.", + insufficientFunds: "The wallet or vault does not have enough available funds. Reduce the amount or try again later.", + contractState: "The vault cannot complete this request right now. Review the amount and vault status, then try again.", + validation: "The request could not be validated. Review the amount and try again.", + unknown: "The transaction could not be completed. Try again or start over.", + }, }, depositMessage: "{{amount}} USDC has been deposited into the vault.", withdrawMessage: "{{amount}} USDC has been withdrawn from the vault.", failedToLoad: "Failed to load vault data", fundName: "Global RWA Yield Fund", tokens: "Tokens: USDC", + position: { + ariaLabel: "Your vault position", + title: "Your vault position", + subtitle: "Your shares represent your portion of the vault.", + value: "Current value", + shares: "Vault shares", + sharePrice: "Share price", + accrual: "Value changes as yield accrues", + noPosition: "Deposit to receive vault shares", + loading: "Loading...", + unavailable: "Unavailable", + helpLabel: "Learn how vault shares work", + help: "Vault value = your yvUSDC shares x the current share price. Your share quantity stays the same while its USDC value can grow as yield accrues.", + }, currentApy: "Current APY", apyTooltip: "Annualized yield based on the historical performance of the vault's underlying assets.", live: "Live", diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index cf5eee38..36366823 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -722,12 +722,35 @@ export const es = { copyFailedDesc: "No se pudo copiar el enlace al portapapeles.", usdcApproved: "USDC aprobado", approvalFailed: "Aprobación fallida", + transactionErrors: { + walletRejected: "Tu billetera canceló la solicitud. Apruébala en Freighter para continuar o selecciona Comenzar de nuevo.", + walletPermission: "Freighter no autorizó esta solicitud. Desbloquea la billetera y aprueba el acceso; después, inténtalo de nuevo.", + network: "La red Stellar o la billetera no respondieron. Comprueba tu conexión e inténtalo de nuevo.", + insufficientFunds: "La billetera o la bóveda no tienen fondos suficientes. Reduce el monto o inténtalo más tarde.", + contractState: "La bóveda no puede completar esta solicitud ahora. Revisa el monto y el estado de la bóveda; después, inténtalo de nuevo.", + validation: "No se pudo validar la solicitud. Revisa el monto e inténtalo de nuevo.", + unknown: "No se pudo completar la transacción. Inténtalo de nuevo o comienza de nuevo.", + }, }, depositMessage: "Se depositaron {{amount}} USDC en la bóveda.", withdrawMessage: "Se retiraron {{amount}} USDC de la bóveda.", failedToLoad: "Error al cargar los datos de la bóveda", fundName: "Fondo Global de Rendimiento RWA", tokens: "Tokens: USDC", + position: { + ariaLabel: "Tu posición en la bóveda", + title: "Tu posición en la bóveda", + subtitle: "Tus participaciones representan tu parte de la bóveda.", + value: "Valor actual", + shares: "Participaciones de la bóveda", + sharePrice: "Precio de participación", + accrual: "El valor cambia mientras se acumula el rendimiento", + noPosition: "Deposita para recibir participaciones", + loading: "Cargando...", + unavailable: "No disponible", + helpLabel: "Aprende cómo funcionan las participaciones", + help: "Valor de la bóveda = tus participaciones yvUSDC x el precio actual. La cantidad de participaciones se mantiene mientras su valor en USDC puede crecer con el rendimiento.", + }, currentApy: "APY actual", apyTooltip: "Rendimiento anualizado basado en el desempeño histórico de los activos subyacentes de la bóveda.", live: "En vivo", diff --git a/frontend/src/lib/errorMappers.ts b/frontend/src/lib/errorMappers.ts index a2e55ea9..c00cfdd5 100644 --- a/frontend/src/lib/errorMappers.ts +++ b/frontend/src/lib/errorMappers.ts @@ -37,6 +37,63 @@ export interface MappedServerError { generalError: string | null; } +export type TransactionErrorKind = + | "walletRejected" + | "walletPermission" + | "network" + | "insufficientFunds" + | "contractState" + | "validation" + | "unknown"; + +export interface MappedTransactionError { + kind: TransactionErrorKind; + retryable: boolean; + technicalCode?: string; + supportReference?: string; +} + +/** Classify wallet/RPC/contract failures without exposing raw provider text. */ +export function mapTransactionError(error: unknown): MappedTransactionError { + const apiError = error && typeof error === "object" + ? error as { code?: string; serverCode?: string; serverError?: string; message?: string; retryable?: boolean; correlationId?: string; traceId?: string } + : undefined; + const raw = [apiError?.serverCode, apiError?.serverError, apiError?.message] + .filter(Boolean) + .join(" ") + .toLowerCase(); + const technicalCode = apiError?.serverCode || apiError?.code; + const supportReference = apiError?.correlationId || apiError?.traceId; + const result = (kind: TransactionErrorKind, retryable: boolean): MappedTransactionError => ({ + kind, + retryable, + technicalCode, + supportReference, + }); + + if (/reject|denied|cancel|declin/.test(raw)) return result("walletRejected", true); + if (/permission|unauthori[sz]|not allowed|wallet.*(locked|disconnected)/.test(raw)) { + return result("walletPermission", true); + } + if (/insufficient|not enough|balance|funds|liquidity/.test(raw)) { + return result("insufficientFunds", false); + } + if (/network|timeout|rpc|service unavailable|fetch failed|gateway/.test(raw)) { + return result("network", true); + } + if (/validation|invalid input|invalid amount/.test(raw)) { + return result("validation", false); + } + if (/validation|invalid|paused|cap|timelock|cooldown|simulation|contract|restore/.test(raw)) { + return result("contractState", false); + } + if (apiError?.code === "AUTH_ERROR" || apiError?.code === "ABORTED") { + return result("walletPermission", false); + } + if (apiError?.retryable === true) return result("network", true); + return result("unknown", false); +} + /** * Map a server error response to form field errors. *