Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/offramp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ function OfframpContent() {
const [trackingAnchorHomeDomain, setTrackingAnchorHomeDomain] = useState<string | null>(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,
Expand Down Expand Up @@ -135,6 +137,7 @@ function OfframpContent() {
</div>
<RateTable
rates={rates}
anchorErrors={anchorErrors}
isLoading={isLoading}
refreshInflight={refreshInflight}
error={error}
Expand Down
42 changes: 41 additions & 1 deletion components/offramp/RateTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import { useState, useCallback } from 'react';
import { formatCurrency, formatRate } from '@/lib/utils';
import type { RateComparison, AnchorRate } from '@/types';
import type { RateComparison, AnchorRate, AnchorRateError } from '@/types';
import { Skeleton } from '@/components/ui/Skeleton';
import { QuotePill } from '@/components/ui/QuotePill';
import { AnchorLogo } from '@/components/ui/AnchorLogo';
Expand All @@ -14,10 +14,12 @@ interface RateTableProps {
onSelectAnchor: (rate: AnchorRate) => 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,
Expand Down Expand Up @@ -86,6 +88,7 @@ export function RateTable({
!error &&
rates &&
rates.rates.length === 0 &&
anchorErrors.length === 0 &&
(!rates.pending || rates.pending.length === 0) && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-gray-500">
Expand Down Expand Up @@ -155,6 +158,43 @@ export function RateTable({
);
})}

{!isLoading &&
!error &&
anchorErrors.map((anchorError) => (
<tr
key={`error-${anchorError.anchorId}`}
className="border-t border-gray-200 dark:border-gray-700 opacity-50"
title={anchorError.reason}
aria-label={`${anchorError.anchorName} unavailable: ${anchorError.reason}`}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<AnchorLogo
anchorId={anchorError.anchorId}
anchorName={anchorError.anchorName}
/>
<span className="font-medium text-gray-400 dark:text-gray-500">
{anchorError.anchorName}
</span>
<QuotePill source="unavailable" />
</div>
</td>
<td className="px-4 py-3 text-right text-gray-400 dark:text-gray-500">—</td>
<td className="px-4 py-3 text-right text-gray-400 dark:text-gray-500">—</td>
<td className="px-4 py-3 text-right text-gray-400 dark:text-gray-500">—</td>
<td className="px-4 py-3 text-right">
<button
disabled
aria-disabled="true"
title={anchorError.reason}
className="rounded-lg bg-gray-200 px-3 py-1.5 text-xs font-medium text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500"
>
Unavailable
</button>
</td>
</tr>
))}

{!isLoading &&
!error &&
rates?.pending?.map((pendingAnchor) => (
Expand Down
4 changes: 3 additions & 1 deletion hooks/useAnchorRates.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -49,6 +49,7 @@ export interface UseAnchorRatesResult {
refreshInflight: boolean;
pauseRefresh: () => void;
resumeRefresh: () => void;
anchorErrors: AnchorRateError[];
}

export function useAnchorRates(
Expand Down Expand Up @@ -203,5 +204,6 @@ export function useAnchorRates(
refreshInflight,
pauseRefresh,
resumeRefresh,
anchorErrors: data?.errors ?? [],
};
}
50 changes: 50 additions & 0 deletions tests/components/RateTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RateTable
rates={mockRates}
anchorErrors={[
{ anchorId: 'bitso', anchorName: 'Bitso', reason: 'SEP-38 timed out after 8000ms' },
]}
isLoading={false}
error={undefined}
onSelectAnchor={vi.fn()}
/>
);
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(
<RateTable
rates={mockRates}
anchorErrors={[
{ anchorId: 'bitso', anchorName: 'Bitso', reason: 'SEP-38 timed out after 8000ms' },
]}
isLoading={false}
error={undefined}
onSelectAnchor={vi.fn()}
/>
);
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(
<RateTable
rates={emptyRates}
anchorErrors={[
{ anchorId: 'bitso', anchorName: 'Bitso', reason: 'SEP-38 timed out after 8000ms' },
]}
isLoading={false}
error={undefined}
onSelectAnchor={vi.fn()}
/>
);
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),
Expand Down
8 changes: 8 additions & 0 deletions types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down
Loading