Skip to content

Commit d234f12

Browse files
authored
Merge branch 'main' into feat/accessible-color-contrast
2 parents bfcd0e6 + 9e9e1bb commit d234f12

40 files changed

Lines changed: 2460 additions & 1431 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ DEX-CHAT converts crypto to fiat through an AI-guided conversation flow. The end
103103

104104
The user connects their Freighter wallet and tells the AI assistant they want to offramp a token amount. The frontend builds a Soroban `deposit` transaction that transfers the specified token from the user's Stellar account into the `FiatBridge` contract. The contract validates the deposit against oracle-sourced prices, enforces slippage limits, checks per-token and daily deposit caps, and records a `Receipt` with a unique memo hash.
105105

106+
For maintainers: how slippage BPS and the on-chain threshold interact is documented in [docs/slippage-threshold.md](docs/slippage-threshold.md).
107+
106108
### 2. Escrow (on-chain hold)
107109

108110
Deposited funds are held in the smart contract's escrow. A withdrawal request is queued with a risk tier that determines the timelock duration — higher-value or higher-risk withdrawals wait longer before they can be executed. During this period the admin dashboard provides real-time metrics (queue depth, oldest request age, accrued fees) so operators can monitor the pipeline.

dex_with_fiat_frontend/src/app/admin/__tests__/page.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,40 @@ describe('AdminDashboard - Dark Mode Support', () => {
9999
const loadingText = screen.getByText('Loading metrics...');
100100
expect(loadingText.className).toContain('theme-text-muted');
101101
});
102+
103+
it('includes proper ARIA accessibility labels', async () => {
104+
render(<AdminDashboard />);
105+
106+
await waitFor(() => {
107+
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
108+
});
109+
110+
// Check chart has ARIA label
111+
const chartContainer = screen.getByRole('img', { name: /transaction volume chart/i });
112+
expect(chartContainer).toBeInTheDocument();
113+
114+
// Check volume display has ARIA label
115+
const volumeDisplay = screen.getByLabelText(/30-day transaction volume/i);
116+
expect(volumeDisplay).toBeInTheDocument();
117+
118+
// Check export button has ARIA label
119+
const exportButton = screen.getByRole('button', { name: /export audit log to csv file/i });
120+
expect(exportButton).toBeInTheDocument();
121+
122+
// Check table has ARIA label
123+
const auditTable = screen.getByRole('table', { name: /admin audit log entries/i });
124+
expect(auditTable).toBeInTheDocument();
125+
126+
// Check pagination buttons have ARIA labels
127+
const prevButton = screen.getByRole('button', { name: /go to previous page/i });
128+
const nextButton = screen.getByRole('button', { name: /go to next page/i });
129+
expect(prevButton).toBeInTheDocument();
130+
expect(nextButton).toBeInTheDocument();
131+
132+
// Check table headers have scope
133+
const headers = screen.getAllByRole('columnheader');
134+
headers.forEach(header => {
135+
expect(header).toHaveAttribute('scope', 'col');
136+
});
137+
});
102138
});

dex_with_fiat_frontend/src/app/admin/page.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -347,12 +347,13 @@ export default function AdminDashboard() {
347347
<h2 className="text-xl font-semibold theme-text-primary mb-6">
348348
Daily Transaction Volume
349349
</h2>
350-
<div className="h-80 w-full relative">
350+
<div className="h-80 w-full relative" role="img" aria-label="Transaction volume chart showing volume over time in XLM">
351351
{maxVolume > 0 ? (
352352
<ResponsiveContainer width="100%" height="100%">
353353
<AreaChart
354354
data={metrics}
355355
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
356+
aria-hidden="true"
356357
>
357358
<defs>
358359
<linearGradient
@@ -448,7 +449,7 @@ export default function AdminDashboard() {
448449
<h2 className="text-lg font-medium theme-text-secondary mb-2">
449450
30-Day Volume (XLM)
450451
</h2>
451-
<div className="text-4xl font-bold theme-text-primary">
452+
<div className="text-4xl font-bold theme-text-primary" aria-label={`30-day transaction volume: ${totalVolume.toLocaleString(undefined, { maximumFractionDigits: 2 })} XLM`}>
452453
{totalVolume.toLocaleString(undefined, {
453454
maximumFractionDigits: 2,
454455
})}
@@ -503,6 +504,8 @@ export default function AdminDashboard() {
503504
backgroundColor: 'var(--color-success)',
504505
color: '#fff',
505506
}}
507+
aria-label={exportingCsv ? 'Exporting audit log to CSV file' : 'Export audit log to CSV file'}
508+
aria-describedby="audit-action-filter"
506509
>
507510
{exportingCsv ? 'Exporting...' : 'Export CSV'}
508511
</button>
@@ -523,22 +526,22 @@ export default function AdminDashboard() {
523526
)}
524527

525528
<div className="overflow-x-auto">
526-
<table className="min-w-full">
529+
<table className="min-w-full" role="table" aria-label="Admin audit log entries">
527530
<thead className="theme-surface-muted">
528531
<tr>
529-
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider">
532+
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider" scope="col">
530533
Timestamp
531534
</th>
532-
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider">
535+
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider" scope="col">
533536
Action
534537
</th>
535-
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider">
538+
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider" scope="col">
536539
Admin Address
537540
</th>
538-
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider">
541+
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider" scope="col">
539542
Parameters
540543
</th>
541-
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider">
544+
<th className="px-6 py-3 text-left text-xs font-semibold theme-text-secondary uppercase tracking-wider" scope="col">
542545
Result
543546
</th>
544547
</tr>
@@ -612,10 +615,11 @@ export default function AdminDashboard() {
612615
}
613616
disabled={auditPage <= 1 || auditLoading}
614617
className="px-3 py-2 text-sm theme-border border rounded-md theme-text-primary theme-surface-muted hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
618+
aria-label={`Go to previous page. Current page is ${auditPage} of ${auditTotalPages}`}
615619
>
616620
Previous
617621
</button>
618-
<span className="text-sm theme-text-secondary">
622+
<span className="text-sm theme-text-secondary" aria-live="polite" aria-atomic="true">
619623
Page {auditPage} of {auditTotalPages}
620624
</span>
621625
<button
@@ -627,6 +631,7 @@ export default function AdminDashboard() {
627631
}
628632
disabled={auditPage >= auditTotalPages || auditLoading}
629633
className="px-3 py-2 text-sm theme-border border rounded-md theme-text-primary theme-surface-muted hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
634+
aria-label={`Go to next page. Current page is ${auditPage} of ${auditTotalPages}`}
630635
>
631636
Next
632637
</button>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import '@testing-library/jest-dom/vitest';
2+
import { describe, it, expect, vi, afterEach } from 'vitest';
3+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
4+
import AuditTable from './AuditTable';
5+
6+
describe('AuditTable', () => {
7+
afterEach(() => {
8+
vi.restoreAllMocks();
9+
});
10+
11+
it('does not apply stale fetch results after a newer request (abort)', async () => {
12+
vi.stubGlobal(
13+
'fetch',
14+
vi.fn((url: string | URL, init?: RequestInit) => {
15+
const u = typeof url === 'string' ? url : url.toString();
16+
const signal = init?.signal;
17+
const isDeposit = u.includes('actionType=deposit');
18+
19+
return new Promise<Response>((resolve, reject) => {
20+
const delayMs = isDeposit ? 200 : 0;
21+
const t = setTimeout(() => {
22+
if (signal?.aborted) {
23+
reject(new DOMException('Aborted', 'AbortError'));
24+
return;
25+
}
26+
resolve({
27+
ok: true,
28+
status: 200,
29+
json: async () => ({
30+
entries: isDeposit
31+
? [
32+
{
33+
id: 'stale',
34+
timestamp: new Date().toISOString(),
35+
adminAddress:
36+
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
37+
actionType: 'deposit',
38+
actionDescription: 'stale-row',
39+
txHash: 'abc',
40+
status: 'success',
41+
},
42+
]
43+
: [
44+
{
45+
id: 'fresh',
46+
timestamp: new Date().toISOString(),
47+
adminAddress:
48+
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
49+
actionType: 'payout',
50+
actionDescription: 'fresh-row',
51+
txHash: 'def',
52+
status: 'success',
53+
},
54+
],
55+
total: 1,
56+
}),
57+
} as Response);
58+
}, delayMs);
59+
60+
signal?.addEventListener('abort', () => {
61+
clearTimeout(t);
62+
reject(new DOMException('Aborted', 'AbortError'));
63+
});
64+
});
65+
}),
66+
);
67+
68+
render(<AuditTable />);
69+
70+
await waitFor(() => {
71+
expect(screen.getByText('fresh-row')).toBeInTheDocument();
72+
});
73+
74+
const actionSelect = screen.getAllByRole('combobox')[0];
75+
fireEvent.change(actionSelect, { target: { value: 'deposit' } });
76+
fireEvent.change(actionSelect, { target: { value: '' } });
77+
78+
await waitFor(
79+
() => {
80+
expect(screen.getByText('fresh-row')).toBeInTheDocument();
81+
},
82+
{ timeout: 4000 },
83+
);
84+
85+
expect(screen.queryByText('stale-row')).not.toBeInTheDocument();
86+
});
87+
});

dex_with_fiat_frontend/src/components/AuditTable.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState, useEffect, useCallback } from 'react';
3+
import { useState, useEffect, useCallback, useRef } from 'react';
44
import { AuditEntry } from '@/types';
55

66
interface AuditTableProps {
@@ -32,8 +32,14 @@ export default function AuditTable({}: AuditTableProps) {
3232
});
3333

3434
const pageSize = 20;
35+
const fetchAbortRef = useRef<AbortController | null>(null);
3536

3637
const fetchAuditEntries = useCallback(async () => {
38+
fetchAbortRef.current?.abort();
39+
const controller = new AbortController();
40+
fetchAbortRef.current = controller;
41+
const { signal } = controller;
42+
3743
setLoading(true);
3844
setError(null);
3945

@@ -50,7 +56,9 @@ export default function AuditTable({}: AuditTableProps) {
5056
params.append('limit', pageSize.toString());
5157
params.append('offset', (currentPage * pageSize).toString());
5258

53-
const response = await fetch(`/api/admin-audit?${params.toString()}`);
59+
const response = await fetch(`/api/admin-audit?${params.toString()}`, {
60+
signal,
61+
});
5462

5563
if (!response.ok) {
5664
throw new Error(`API error: ${response.statusText}`);
@@ -63,15 +71,23 @@ export default function AuditTable({}: AuditTableProps) {
6371
})));
6472
setTotalEntries(data.total);
6573
} catch (err) {
74+
if (err instanceof DOMException && err.name === 'AbortError') {
75+
return;
76+
}
6677
setError(err instanceof Error ? err.message : 'Failed to fetch audit entries');
6778
console.error('Audit fetch error:', err);
6879
} finally {
69-
setLoading(false);
80+
if (!signal.aborted) {
81+
setLoading(false);
82+
}
7083
}
7184
}, [filters, currentPage]);
7285

7386
useEffect(() => {
74-
fetchAuditEntries();
87+
void fetchAuditEntries();
88+
return () => {
89+
fetchAbortRef.current?.abort();
90+
};
7591
}, [fetchAuditEntries]);
7692

7793
const handleFilterChange = (key: keyof FilterState, value: string) => {

dex_with_fiat_frontend/src/components/BankDetailsModal.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import CopyButton from '@/components/ui/CopyButton';
3030
import { useAccessibleModal } from '@/hooks/useAccessibleModal';
3131
import { useIdempotentAction } from '@/hooks/useIdempotentAction';
3232
import { getOrCreateClientSessionId } from '@/lib/clientSession';
33+
import { chatTelemetry } from '@/lib/chatTelemetry';
3334

3435
interface Bank {
3536
id: number;
@@ -140,6 +141,19 @@ export default function BankDetailsModal({
140141
]);
141142
};
142143

144+
const wasOpenRef = useRef(false);
145+
useEffect(() => {
146+
if (isOpen && !wasOpenRef.current) {
147+
chatTelemetry.fiatPayoutStep({ action: 'open', step: 1, xlmAmount });
148+
}
149+
wasOpenRef.current = isOpen;
150+
}, [isOpen, xlmAmount]);
151+
152+
useEffect(() => {
153+
if (!isOpen) return;
154+
chatTelemetry.fiatPayoutStep({ action: 'step_change', step, xlmAmount });
155+
}, [step, isOpen, xlmAmount]);
156+
143157
// Fetch banks when modal opens
144158
useEffect(() => {
145159
if (!isOpen) return;
@@ -245,11 +259,28 @@ export default function BankDetailsModal({
245259
} = await res.json();
246260
if (json.success) {
247261
setVerifiedAccount(json.data);
262+
chatTelemetry.fiatPayoutStep({
263+
action: 'account_verify_success',
264+
step: 2,
265+
xlmAmount,
266+
});
248267
} else {
249268
setVerifyError(json.message ?? 'Account verification failed');
269+
chatTelemetry.fiatPayoutStep({
270+
action: 'account_verify_fail',
271+
step: 2,
272+
xlmAmount,
273+
errorMessage: json.message ?? 'Account verification failed',
274+
});
250275
}
251276
} catch {
252277
setVerifyError('Account verification failed. Please try again.');
278+
chatTelemetry.fiatPayoutStep({
279+
action: 'account_verify_fail',
280+
step: 2,
281+
xlmAmount,
282+
errorMessage: 'network_error',
283+
});
253284
} finally {
254285
setVerifying(false);
255286
}
@@ -266,6 +297,11 @@ export default function BankDetailsModal({
266297
return;
267298

268299
await executePayoutConfirm(async (idempotencyKey) => {
300+
chatTelemetry.fiatPayoutStep({
301+
action: 'confirm_attempt',
302+
step: 3,
303+
xlmAmount,
304+
});
269305
setPayoutLoading(true);
270306
setPayoutError('');
271307
setStatusEvents([]);
@@ -355,6 +391,11 @@ export default function BankDetailsModal({
355391
setIsPollingStatus(false);
356392
pushStatusEvent('success', 'Bank transfer confirmed');
357393
setStep(4);
394+
chatTelemetry.fiatPayoutStep({
395+
action: 'confirm_success',
396+
step: 4,
397+
xlmAmount,
398+
});
358399
addNotification(
359400
'payout_success',
360401
'Fiat payout successfully completed!',
@@ -364,6 +405,12 @@ export default function BankDetailsModal({
364405
err instanceof Error
365406
? err.message
366407
: 'Payout failed. Please try again.';
408+
chatTelemetry.fiatPayoutStep({
409+
action: 'confirm_error',
410+
step: 3,
411+
xlmAmount,
412+
errorMessage: errorMsg,
413+
});
367414
setPayoutError(errorMsg);
368415
setIsPollingStatus(false);
369416
pushStatusEvent('failed', `Transfer failed: ${errorMsg}`);
@@ -375,6 +422,7 @@ export default function BankDetailsModal({
375422
};
376423

377424
const handleClose = () => {
425+
chatTelemetry.fiatPayoutStep({ action: 'close', step, xlmAmount });
378426
// Reset all state before closing
379427
setStep(1);
380428
setBanks([]);
@@ -626,7 +674,15 @@ export default function BankDetailsModal({
626674
<button
627675
key={bank.id}
628676
type="button"
629-
onClick={() => setSelectedBank(bank)}
677+
onClick={() => {
678+
setSelectedBank(bank);
679+
chatTelemetry.fiatPayoutStep({
680+
action: 'bank_selected',
681+
step: 1,
682+
xlmAmount,
683+
bankCode: bank.code,
684+
});
685+
}}
630686
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
631687
selectedBank?.id === bank.id
632688
? 'bg-blue-600 text-white'

0 commit comments

Comments
 (0)