From 6562ad1a033de3d9f006ed14344787319ae678fd Mon Sep 17 00:00:00 2001 From: ezedike-evan <210902543@live.unilag.edu.ng> Date: Sun, 5 Jul 2026 09:06:25 +0100 Subject: [PATCH] feat(offramp): surface anchor errors as unavailable rows Closes #474 --- app/offramp/page.tsx | 7 ++-- components/offramp/RateTable.tsx | 42 +++++++++++++++++++++++- hooks/useAnchorRates.ts | 4 ++- tests/components/RateTable.test.tsx | 50 +++++++++++++++++++++++++++++ types/index.ts | 8 +++++ 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/app/offramp/page.tsx b/app/offramp/page.tsx index 03bc89a6..973c138c 100644 --- a/app/offramp/page.tsx +++ b/app/offramp/page.tsx @@ -36,8 +36,10 @@ function OfframpContent() { const [trackingAnchorHomeDomain, setTrackingAnchorHomeDomain] = useState(null); const { isConnected, publicKey, network } = useWallet(); - const { rates, isLoading, error, mutate, refreshInflight } = useAnchorRates(corridorId, amount); - + const { rates, anchorErrors, isLoading, error, mutate, refreshInflight } = useAnchorRates( + corridorId, + amount + ); const withdrawStatus = useWithdrawStatus( trackingTransferServer, trackingTransactionId, @@ -135,6 +137,7 @@ function OfframpContent() { void; /** Disables the off-ramp action (e.g. when the wallet is not on mainnet). */ executeDisabled?: boolean; + anchorErrors?: AnchorRateError[]; } export function RateTable({ rates, + anchorErrors = [], isLoading, refreshInflight, error, @@ -86,6 +88,7 @@ export function RateTable({ !error && rates && rates.rates.length === 0 && + anchorErrors.length === 0 && (!rates.pending || rates.pending.length === 0) && ( @@ -155,6 +158,43 @@ export function RateTable({ ); })} + {!isLoading && + !error && + anchorErrors.map((anchorError) => ( + + +
+ + + {anchorError.anchorName} + + +
+ + — + — + — + + + + + ))} + {!isLoading && !error && rates?.pending?.map((pendingAnchor) => ( diff --git a/hooks/useAnchorRates.ts b/hooks/useAnchorRates.ts index 39ba2b79..abfac4c7 100644 --- a/hooks/useAnchorRates.ts +++ b/hooks/useAnchorRates.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import useSWR from 'swr'; import { measureClient } from '@/lib/metrics'; -import type { AnchorRate, RateComparison } from '@/types'; +import type { AnchorRate, AnchorRateError, RateComparison } from '@/types'; const RATES_REFRESH_INTERVAL_MS = 30_000; @@ -49,6 +49,7 @@ export interface UseAnchorRatesResult { refreshInflight: boolean; pauseRefresh: () => void; resumeRefresh: () => void; + anchorErrors: AnchorRateError[]; } export function useAnchorRates( @@ -203,5 +204,6 @@ export function useAnchorRates( refreshInflight, pauseRefresh, resumeRefresh, + anchorErrors: data?.errors ?? [], }; } diff --git a/tests/components/RateTable.test.tsx b/tests/components/RateTable.test.tsx index d376c538..1f2b5232 100644 --- a/tests/components/RateTable.test.tsx +++ b/tests/components/RateTable.test.tsx @@ -81,6 +81,56 @@ describe('RateTable', () => { expect(screen.getByText('No rates available for this corridor.')).toBeInTheDocument(); }); + it('renders an unavailable row for each anchorError', () => { + render( + + ); + expect(screen.getByText('Bitso')).toBeInTheDocument(); + const unavailableBtn = screen.getByRole('button', { name: /unavailable/i }); + expect(unavailableBtn).toBeDisabled(); + }); + + it('unavailable row button has the error reason as its title attribute', () => { + render( + + ); + const unavailableBtn = screen.getByRole('button', { name: /unavailable/i }); + expect(unavailableBtn).toHaveAttribute('title', 'SEP-38 timed out after 8000ms'); + }); + + it('does not show empty state when there are no rates but there are anchorErrors', () => { + const emptyRates: RateComparison = { ...mockRates, rates: [], bestRateId: '' }; + render( + + ); + expect(screen.queryByText('No rates available for this corridor.')).not.toBeInTheDocument(); + expect(screen.getByText('Bitso')).toBeInTheDocument(); + }); + it('renders "Indicative (SEP-6)" badge for sep6-info rows', () => { const sep6Rate: AnchorRate = { ...makeRate('yellowcard', 152000), diff --git a/types/index.ts b/types/index.ts index 68eece34..13c29d7b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -57,12 +57,20 @@ export interface AnchorRate { quoteStatus?: 'firm' | 'expiring' | 'refreshing'; } +export interface AnchorRateError { + anchorId: string; + anchorName: string; + reason: string; +} + /** The result of comparing all anchor rates for a single corridor. */ export interface RateComparison { corridorId: string; rates: AnchorRate[]; pending: { anchorId: string; anchorName: string }[]; // Anchors still resolving bestRateId: string; // anchorId of the anchor with the highest totalReceived + + errors?: AnchorRateError[]; } // ─── Wallet ───────────────────────────────────────────────────────────────────