Skip to content

Commit b94a4b7

Browse files
committed
fix: onramp popup polling, retry state, and non-terminal commit errors
- Poll backend at 1s from popup open for immediate status detection - Add 15s confirmation timeout when popup signals success - Set terminal ref on success signal to prevent popup-closed race - Clear failed/error state on retry events in popup (pending_payment_auth, payment_authorized, apple_pay_button_pressed) - Treat commit_error as non-terminal (UI falls back to QR code) - Animate status bar with slide-down transition instead of shifting iframe - Use HeaderInner title/description props to fix text truncation
1 parent d882002 commit b94a4b7

File tree

2 files changed

+55
-88
lines changed

2 files changed

+55
-88
lines changed

packages/keychain/src/components/coinbase-popup.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,9 @@ export function CoinbasePopup() {
205205
break;
206206

207207
case "onramp_api.commit_error":
208-
setError(
209-
data.data?.errorMessage ||
210-
"Payment could not be processed. Please try again.",
211-
);
208+
// Don't treat as terminal — Coinbase falls back to QR code
209+
// when Apple Pay is unsupported or fails.
212210
setCommitted(false);
213-
setFailed(true);
214-
setCompleted(false);
215211
break;
216212

217213
case "onramp_api.cancel":

packages/keychain/src/hooks/starterpack/coinbase.ts

Lines changed: 53 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,8 @@ export interface CoinbaseQuoteInput {
3232
sandbox?: boolean;
3333
}
3434

35-
/** Slow fallback poll interval while popup is open (5 seconds) */
36-
const FALLBACK_POLL_INTERVAL_MS = 5_000;
37-
/** Fast polling interval after popup reports success (1 second) */
38-
const CONFIRMATION_POLL_INTERVAL_MS = 1_000;
35+
/** Polling interval for order status (1 second) */
36+
const POLL_INTERVAL_MS = 1_000;
3937
/** Timeout for the fast confirmation poll after popup reports success (15 seconds) */
4038
const CONFIRMATION_TIMEOUT_MS = 15_000;
4139

@@ -147,13 +145,12 @@ export function useCoinbase({
147145
const popupRef = useRef<Window | null>(null);
148146
const channelRef = useRef<BroadcastChannel | null>(null);
149147
const popupCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
150-
const fallbackPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
151-
const confirmPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
148+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
152149
const confirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
153150
/** Whether a terminal status has been reached (prevents popup-closed from overriding) */
154151
const terminalReachedRef = useRef(false);
155152

156-
/** Clean up BroadcastChannel, popup watcher, and all polls */
153+
/** Clean up BroadcastChannel, popup watcher, and poll */
157154
const cleanup = useCallback(() => {
158155
if (channelRef.current) {
159156
channelRef.current.close();
@@ -163,13 +160,9 @@ export function useCoinbase({
163160
clearInterval(popupCheckRef.current);
164161
popupCheckRef.current = null;
165162
}
166-
if (fallbackPollRef.current) {
167-
clearInterval(fallbackPollRef.current);
168-
fallbackPollRef.current = null;
169-
}
170-
if (confirmPollRef.current) {
171-
clearInterval(confirmPollRef.current);
172-
confirmPollRef.current = null;
163+
if (pollRef.current) {
164+
clearInterval(pollRef.current);
165+
pollRef.current = null;
173166
}
174167
if (confirmTimeoutRef.current) {
175168
clearTimeout(confirmTimeoutRef.current);
@@ -180,23 +173,19 @@ export function useCoinbase({
180173
// Clean up on unmount
181174
useEffect(() => cleanup, [cleanup]);
182175

183-
/** Stop all polling (fallback + confirmation) */
184-
const stopAllPolls = useCallback(() => {
185-
if (fallbackPollRef.current) {
186-
clearInterval(fallbackPollRef.current);
187-
fallbackPollRef.current = null;
188-
}
189-
if (confirmPollRef.current) {
190-
clearInterval(confirmPollRef.current);
191-
confirmPollRef.current = null;
176+
/** Stop polling and clear timeout */
177+
const stopPoll = useCallback(() => {
178+
if (pollRef.current) {
179+
clearInterval(pollRef.current);
180+
pollRef.current = null;
192181
}
193182
if (confirmTimeoutRef.current) {
194183
clearTimeout(confirmTimeoutRef.current);
195184
confirmTimeoutRef.current = null;
196185
}
197186
}, []);
198187

199-
/** Shared poll function — queries backend and handles terminal statuses */
188+
/** Poll backend once — handles terminal statuses */
200189
const pollOnce = useCallback(
201190
async (targetOrderId: string) => {
202191
try {
@@ -209,80 +198,54 @@ export function useCoinbase({
209198
if (result.status === CoinbaseOnrampStatus.Completed) {
210199
setOrderStatus(CoinbaseOnrampStatus.Completed);
211200
terminalReachedRef.current = true;
212-
stopAllPolls();
201+
stopPoll();
213202
} else if (result.status === CoinbaseOnrampStatus.Failed) {
214203
setOrderStatus(CoinbaseOnrampStatus.Failed);
215204
terminalReachedRef.current = true;
216-
stopAllPolls();
205+
stopPoll();
217206
onError?.(new Error("Coinbase order failed."));
218207
}
219208
} catch (err) {
220209
console.error("Failed to poll Coinbase order status:", err);
221210
// Don't stop on transient errors
222211
}
223212
},
224-
[onError, stopAllPolls],
213+
[onError, stopPoll],
225214
);
226215

227-
/**
228-
* Slow fallback poll (5s) — starts as soon as the popup opens.
229-
* Catches completions even if the BroadcastChannel signal is lost.
230-
*/
231-
const startFallbackPoll = useCallback(
216+
/** Start 1s polling for order status */
217+
const startPoll = useCallback(
232218
(targetOrderId: string) => {
233-
if (fallbackPollRef.current) return;
219+
if (pollRef.current) return;
234220

235-
fallbackPollRef.current = setInterval(
221+
pollRef.current = setInterval(
236222
() => pollOnce(targetOrderId),
237-
FALLBACK_POLL_INTERVAL_MS,
223+
POLL_INTERVAL_MS,
238224
);
239225
},
240226
[pollOnce],
241227
);
242228

243229
/**
244-
* Fast confirmation poll (1s) — starts when popup reports polling_success.
245-
* Replaces the fallback poll. Times out after 15s.
230+
* Start a 15s confirmation timeout. If the backend doesn't confirm
231+
* Completed within this window, treat it as fatal.
246232
*/
247-
const startConfirmationPoll = useCallback(
248-
(targetOrderId: string) => {
249-
// Stop the slow fallback poll — we're upgrading to fast
250-
if (fallbackPollRef.current) {
251-
clearInterval(fallbackPollRef.current);
252-
fallbackPollRef.current = null;
233+
const startConfirmationTimeout = useCallback(() => {
234+
if (confirmTimeoutRef.current) return;
235+
236+
confirmTimeoutRef.current = setTimeout(() => {
237+
if (!terminalReachedRef.current) {
238+
stopPoll();
239+
setOrderStatus(CoinbaseOnrampStatus.Failed);
240+
terminalReachedRef.current = true;
241+
onError?.(
242+
new Error(
243+
"Payment confirmation timed out. Your card may have been charged — please contact support.",
244+
),
245+
);
253246
}
254-
255-
// Avoid duplicate confirmation polls
256-
if (confirmPollRef.current) return;
257-
258-
// Immediate first poll
259-
pollOnce(targetOrderId);
260-
261-
// Subsequent fast polls
262-
confirmPollRef.current = setInterval(
263-
() => pollOnce(targetOrderId),
264-
CONFIRMATION_POLL_INTERVAL_MS,
265-
);
266-
267-
// Timeout: if status never reaches Completed, treat as fatal
268-
confirmTimeoutRef.current = setTimeout(() => {
269-
if (confirmPollRef.current) {
270-
clearInterval(confirmPollRef.current);
271-
confirmPollRef.current = null;
272-
}
273-
if (!terminalReachedRef.current) {
274-
setOrderStatus(CoinbaseOnrampStatus.Failed);
275-
terminalReachedRef.current = true;
276-
onError?.(
277-
new Error(
278-
"Payment confirmation timed out. Your card may have been charged — please contact support.",
279-
),
280-
);
281-
}
282-
}, CONFIRMATION_TIMEOUT_MS);
283-
},
284-
[onError, pollOnce],
285-
);
247+
}, CONFIRMATION_TIMEOUT_MS);
248+
}, [onError, stopPoll]);
286249

287250
/** Open the payment link in a popup and listen via BroadcastChannel */
288251
const openPaymentPopup = useCallback(
@@ -319,8 +282,8 @@ export function useCoinbase({
319282
);
320283
popupRef.current = popup;
321284

322-
// Start slow fallback poll immediately (catches completions if signal is lost)
323-
startFallbackPoll(targetOrderId);
285+
// Start 1s poll immediately (catches completions even if BroadcastChannel signal is lost)
286+
startPoll(targetOrderId);
324287

325288
// Listen for events from the popup via BroadcastChannel
326289
const channel = new BroadcastChannel(`coinbase-payment-${targetOrderId}`);
@@ -338,12 +301,11 @@ export function useCoinbase({
338301
case "onramp_api.polling_success":
339302
// Mark terminal immediately so popup-close watcher doesn't interfere
340303
terminalReachedRef.current = true;
341-
// Popup reports success — start tight backend poll for txHash
342-
startConfirmationPoll(targetOrderId);
304+
// Add a 15s timeout — poll is already running at 1s
305+
startConfirmationTimeout();
343306
break;
344307

345308
case "onramp_api.polling_error":
346-
case "onramp_api.commit_error":
347309
case "onramp_api.load_error":
348310
setOrderStatus(CoinbaseOnrampStatus.Failed);
349311
terminalReachedRef.current = true;
@@ -352,6 +314,15 @@ export function useCoinbase({
352314
);
353315
break;
354316

317+
case "onramp_api.commit_error":
318+
// Don't treat as terminal — Coinbase falls back to QR code
319+
// when Apple Pay is unsupported or fails.
320+
console.warn(
321+
"[coinbase-hook] commit_error (non-terminal):",
322+
data?.errorMessage,
323+
);
324+
break;
325+
355326
case "onramp_api.cancel":
356327
setOrderStatus(CoinbaseOnrampStatus.Failed);
357328
terminalReachedRef.current = true;
@@ -378,8 +349,8 @@ export function useCoinbase({
378349
paymentLink,
379350
orderId,
380351
cleanup,
381-
startFallbackPoll,
382-
startConfirmationPoll,
352+
startPoll,
353+
startConfirmationTimeout,
383354
onError,
384355
],
385356
);

0 commit comments

Comments
 (0)