Skip to content

Commit 3896ae7

Browse files
committed
feat(frontend): add ccip bridge status polling
1 parent 610a059 commit 3896ae7

3 files changed

Lines changed: 445 additions & 0 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
'use client';
2+
3+
import React, { useCallback, useEffect, useRef, useState } from 'react';
4+
import {
5+
AlertCircle,
6+
CheckCircle,
7+
ExternalLink,
8+
Loader2,
9+
X,
10+
} from 'lucide-react';
11+
import { useAccessibleModal } from '@/hooks/useAccessibleModal';
12+
import {
13+
buildCCIPExplorerTransactionUrl,
14+
CCIP_POLL_INTERVAL_MS,
15+
CCIP_POLL_TIMEOUT_MS,
16+
type CCIPStatusResult,
17+
type CCIPTransferStartResult,
18+
} from '@/lib/ccipExplorer';
19+
20+
type BridgeState = 'idle' | 'initiating' | 'polling' | 'success' | 'error';
21+
22+
export interface CCIPBridgeModalProps {
23+
isOpen: boolean;
24+
onClose: () => void;
25+
onStartTransfer: () => Promise<CCIPTransferStartResult>;
26+
fetchTransferStatus: (transactionHash: string) => Promise<CCIPStatusResult>;
27+
pollIntervalMs?: number;
28+
timeoutMs?: number;
29+
}
30+
31+
export default function CCIPBridgeModal({
32+
isOpen,
33+
onClose,
34+
onStartTransfer,
35+
fetchTransferStatus,
36+
pollIntervalMs = CCIP_POLL_INTERVAL_MS,
37+
timeoutMs = CCIP_POLL_TIMEOUT_MS,
38+
}: CCIPBridgeModalProps) {
39+
const modalRef = useRef<HTMLDivElement>(null);
40+
const pollingStartedAtRef = useRef<number | null>(null);
41+
const [bridgeState, setBridgeState] = useState<BridgeState>('idle');
42+
const [transactionHash, setTransactionHash] = useState('');
43+
const [explorerUrl, setExplorerUrl] = useState('');
44+
const [latestStatus, setLatestStatus] = useState<string>('');
45+
const [errorMessage, setErrorMessage] = useState('');
46+
47+
useAccessibleModal(isOpen, modalRef, onClose);
48+
49+
const resetState = useCallback(() => {
50+
pollingStartedAtRef.current = null;
51+
setBridgeState('idle');
52+
setTransactionHash('');
53+
setExplorerUrl('');
54+
setLatestStatus('');
55+
setErrorMessage('');
56+
}, []);
57+
58+
useEffect(() => {
59+
if (!isOpen) {
60+
resetState();
61+
}
62+
}, [isOpen, resetState]);
63+
64+
const handleStartTransfer = useCallback(async () => {
65+
setBridgeState('initiating');
66+
setErrorMessage('');
67+
68+
try {
69+
const result = await onStartTransfer();
70+
const nextHash = result.transactionHash.trim();
71+
72+
if (!nextHash) {
73+
throw new Error('CCIP transfer did not return a transaction hash.');
74+
}
75+
76+
pollingStartedAtRef.current = Date.now();
77+
setTransactionHash(nextHash);
78+
setExplorerUrl(
79+
result.explorerUrl ?? buildCCIPExplorerTransactionUrl(nextHash),
80+
);
81+
setLatestStatus('PENDING');
82+
setBridgeState('polling');
83+
} catch (error) {
84+
setBridgeState('error');
85+
setErrorMessage(
86+
error instanceof Error
87+
? error.message
88+
: 'Unable to start the CCIP transfer.',
89+
);
90+
}
91+
}, [onStartTransfer]);
92+
93+
const pollTransferStatus = useCallback(async () => {
94+
if (!transactionHash) {
95+
return;
96+
}
97+
98+
const pollingStartedAt = pollingStartedAtRef.current;
99+
if (
100+
pollingStartedAt !== null &&
101+
Date.now() - pollingStartedAt >= timeoutMs
102+
) {
103+
setBridgeState('error');
104+
setErrorMessage(
105+
'CCIP confirmation timed out after 10 minutes. Please verify the transaction in the explorer and try again.',
106+
);
107+
return;
108+
}
109+
110+
try {
111+
const result = await fetchTransferStatus(transactionHash);
112+
setLatestStatus(result.status);
113+
if (result.explorerUrl) {
114+
setExplorerUrl(result.explorerUrl);
115+
}
116+
117+
if (result.status === 'SUCCESS') {
118+
setBridgeState('success');
119+
return;
120+
}
121+
122+
if (result.status === 'FAILED' || result.status === 'ERROR') {
123+
setBridgeState('error');
124+
setErrorMessage(
125+
result.errorMessage ??
126+
`CCIP transfer failed with status "${result.status}".`,
127+
);
128+
return;
129+
}
130+
131+
setBridgeState('polling');
132+
} catch (error) {
133+
setLatestStatus('PENDING');
134+
setBridgeState('polling');
135+
if (
136+
pollingStartedAt !== null &&
137+
Date.now() - pollingStartedAt >= timeoutMs
138+
) {
139+
setBridgeState('error');
140+
setErrorMessage(
141+
error instanceof Error
142+
? error.message
143+
: 'CCIP confirmation timed out after 10 minutes.',
144+
);
145+
}
146+
}
147+
}, [fetchTransferStatus, timeoutMs, transactionHash]);
148+
149+
useEffect(() => {
150+
if (
151+
!isOpen ||
152+
!transactionHash ||
153+
bridgeState === 'idle' ||
154+
bridgeState === 'success' ||
155+
bridgeState === 'error'
156+
) {
157+
return;
158+
}
159+
160+
void pollTransferStatus();
161+
const intervalId = window.setInterval(() => {
162+
void pollTransferStatus();
163+
}, pollIntervalMs);
164+
165+
return () => {
166+
window.clearInterval(intervalId);
167+
};
168+
}, [
169+
bridgeState,
170+
isOpen,
171+
pollIntervalMs,
172+
pollTransferStatus,
173+
transactionHash,
174+
]);
175+
176+
if (!isOpen) {
177+
return null;
178+
}
179+
180+
return (
181+
<div className="theme-overlay fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
182+
<div
183+
ref={modalRef}
184+
role="dialog"
185+
aria-modal="true"
186+
aria-label="CCIP bridge transfer"
187+
tabIndex={-1}
188+
className="theme-surface theme-border relative w-full max-w-md mx-4 border rounded-2xl shadow-2xl p-6"
189+
>
190+
<div className="flex items-center justify-between mb-6">
191+
<div>
192+
<h2 className="theme-text-primary text-lg font-semibold">
193+
CCIP Bridge
194+
</h2>
195+
<p className="theme-text-secondary text-sm mt-1">
196+
Start a CCIP transfer and monitor its confirmation state.
197+
</p>
198+
</div>
199+
<button
200+
type="button"
201+
onClick={onClose}
202+
aria-label="Close"
203+
className="theme-text-muted hover:theme-text-primary transition-colors"
204+
>
205+
<X className="w-5 h-5" />
206+
</button>
207+
</div>
208+
209+
{bridgeState === 'idle' && (
210+
<button
211+
type="button"
212+
onClick={() => void handleStartTransfer()}
213+
className="theme-primary-button w-full py-3 rounded-lg font-medium"
214+
>
215+
Start CCIP Transfer
216+
</button>
217+
)}
218+
219+
{bridgeState === 'initiating' && (
220+
<div className="flex items-center justify-center gap-3 py-10">
221+
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
222+
<span className="theme-text-primary text-sm">
223+
Starting CCIP transfer…
224+
</span>
225+
</div>
226+
)}
227+
228+
{bridgeState === 'polling' && (
229+
<div className="text-center py-6">
230+
<Loader2
231+
data-testid="ccip-polling-spinner"
232+
className="w-14 h-14 text-blue-400 mx-auto mb-4 animate-spin"
233+
/>
234+
<p className="theme-text-primary font-semibold text-lg mb-2">
235+
Waiting for CCIP confirmation…
236+
</p>
237+
{latestStatus && (
238+
<p className="theme-text-secondary text-sm mb-4">
239+
Latest status: {latestStatus}
240+
</p>
241+
)}
242+
{transactionHash && (
243+
<p className="theme-text-secondary text-xs mb-4 break-all">
244+
Transaction: {transactionHash}
245+
</p>
246+
)}
247+
{explorerUrl && (
248+
<a
249+
href={explorerUrl}
250+
target="_blank"
251+
rel="noreferrer"
252+
className="inline-flex items-center gap-2 text-blue-400 hover:underline text-sm"
253+
>
254+
View transaction in CCIP explorer
255+
<ExternalLink className="w-4 h-4" />
256+
</a>
257+
)}
258+
</div>
259+
)}
260+
261+
{bridgeState === 'success' && (
262+
<div className="text-center py-6">
263+
<CheckCircle
264+
data-testid="ccip-success-icon"
265+
className="w-14 h-14 text-green-500 mx-auto mb-4"
266+
/>
267+
<p className="theme-text-primary font-semibold text-lg mb-2">
268+
CCIP transfer confirmed
269+
</p>
270+
<p className="theme-text-secondary text-sm mb-4">
271+
Status: {latestStatus || 'SUCCESS'}
272+
</p>
273+
{explorerUrl && (
274+
<a
275+
href={explorerUrl}
276+
target="_blank"
277+
rel="noreferrer"
278+
className="inline-flex items-center gap-2 text-blue-400 hover:underline text-sm"
279+
>
280+
View transaction in CCIP explorer
281+
<ExternalLink className="w-4 h-4" />
282+
</a>
283+
)}
284+
</div>
285+
)}
286+
287+
{bridgeState === 'error' && (
288+
<div className="text-center py-6">
289+
<AlertCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
290+
<p className="theme-text-primary font-semibold text-lg mb-2">
291+
CCIP transfer error
292+
</p>
293+
<p className="theme-text-secondary text-sm mb-4">
294+
{errorMessage}
295+
</p>
296+
{explorerUrl && (
297+
<a
298+
href={explorerUrl}
299+
target="_blank"
300+
rel="noreferrer"
301+
className="inline-flex items-center gap-2 text-blue-400 hover:underline text-sm"
302+
>
303+
View transaction in CCIP explorer
304+
<ExternalLink className="w-4 h-4" />
305+
</a>
306+
)}
307+
</div>
308+
)}
309+
</div>
310+
</div>
311+
);
312+
}

0 commit comments

Comments
 (0)