Skip to content
Merged
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
181 changes: 115 additions & 66 deletions packages/keychain/src/components/coinbase-popup.tsx
Original file line number Diff line number Diff line change
@@ -1,93 +1,135 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { SpinnerIcon, TimesIcon } from "@cartridge/ui";
import {
CoinbaseOnRampOrderDocument,
CoinbaseOnRampOrderQuery,
CoinbaseOnrampStatus,
} from "@cartridge/ui/utils/api/cartridge";
import { request } from "@/utils/graphql";

/** Polling interval for checking order status (1 second for responsive UX) */
const POLL_INTERVAL_MS = 1_000;

/** Timeout for the payment (10 minutes) */
const PAYMENT_TIMEOUT_MS = 10 * 60 * 1000;

/** Coinbase postMessage event names */
type CoinbaseEventName =
| "onramp_api.load_pending"
| "onramp_api.load_success"
| "onramp_api.load_error"
| "onramp_api.commit_success"
| "onramp_api.commit_error"
| "onramp_api.cancel"
| "onramp_api.polling_start"
| "onramp_api.polling_success"
| "onramp_api.polling_error";

interface CoinbasePostMessage {
eventName: CoinbaseEventName;
data?: {
errorCode?: string;
errorMessage?: string;
};
}

/**
* Standalone page rendered at /coinbase in the keychain app.
* Opened as a popup from the CoinbaseCheckout component.
*
* - Embeds the Coinbase payment link in an iframe (works because
* the top-level domain is x.cartridge.gg)
* - Polls the order status via GraphQL
* - Listens for Coinbase postMessage events for real-time feedback
* - Closes the popup automatically on success
* - Shows failure message if payment fails or times out
* - Shows failure/error messages from Coinbase events
*/
export function CoinbasePopup() {
const [searchParams] = useSearchParams();
const paymentLink = searchParams.get("paymentLink");
const orderId = searchParams.get("orderId");

const [iframeLoaded, setIframeLoaded] = useState(false);
const [status, setStatus] = useState<CoinbaseOnrampStatus | undefined>();
const [iframeReady, setIframeReady] = useState(false);
const [error, setError] = useState<string | undefined>();
const [committed, setCommitted] = useState(false);
const [completed, setCompleted] = useState(false);
const [failed, setFailed] = useState(false);

const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
}, []);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Start polling when we have an orderId
// Listen for Coinbase postMessage events from the iframe
useEffect(() => {
if (!orderId) return;

const poll = async () => {
try {
const result = await request<CoinbaseOnRampOrderQuery>(
CoinbaseOnRampOrderDocument,
{ orderId },
// Derive the allowed origin from the payment link URL
const allowedOrigin = paymentLink ? new URL(paymentLink).origin : null;

const handleMessage = (event: MessageEvent) => {
// Verify the message origin matches the Coinbase payment link domain
if (allowedOrigin && event.origin !== allowedOrigin) {
console.error(
`[coinbase-popup] Rejected postMessage from unexpected origin: ${event.origin} (expected: ${allowedOrigin})`,
);
const orderStatus = result.coinbaseOnrampOrder.status;
setStatus(orderStatus);
return;
}

if (orderStatus === CoinbaseOnrampStatus.Completed) {
stopPolling();
// Brief delay so the user sees the success state before auto-close
// Only process messages that look like Coinbase events
const data = event.data as CoinbasePostMessage;
if (!data?.eventName?.startsWith("onramp_api.")) return;

console.log("[coinbase-popup] event:", data.eventName, data.data);

switch (data.eventName) {
case "onramp_api.load_success":
setIframeReady(true);
break;

case "onramp_api.load_error":
setIframeReady(true); // hide spinner even on error
setError(
data.data?.errorMessage ||
"Failed to load payment. Please close and try again.",
);
setFailed(true);
break;

case "onramp_api.commit_success":
setCommitted(true);
break;

case "onramp_api.commit_error":
setError(
data.data?.errorMessage ||
"Payment could not be processed. Please try again.",
);
setFailed(true);
break;

case "onramp_api.cancel":
setError("Payment was cancelled.");
break;

case "onramp_api.polling_success":
setCompleted(true);
setTimeout(() => window.close(), 1500);
} else if (orderStatus === CoinbaseOnrampStatus.Failed) {
stopPolling();
setError("Payment failed. You can close this window and try again.");
}
} catch (err) {
console.error("Failed to poll order status:", err);
// Don't stop polling on transient errors
break;

case "onramp_api.polling_error":
setError(
data.data?.errorMessage ||
"Transaction failed. Please close and try again.",
);
setFailed(true);
break;
}
};

// Immediate first poll
poll();
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [paymentLink]);

// Subsequent polls
pollIntervalRef.current = setInterval(poll, POLL_INTERVAL_MS);

// Timeout
pollTimeoutRef.current = setTimeout(() => {
stopPolling();
// Timeout safety net
useEffect(() => {
timeoutRef.current = setTimeout(() => {
setError("Payment timed out. Please close this window and try again.");
setStatus(CoinbaseOnrampStatus.Failed);
setFailed(true);
}, PAYMENT_TIMEOUT_MS);

return () => stopPolling();
}, [orderId, stopPolling]);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);

if (!paymentLink || !orderId) {
return (
Expand All @@ -97,21 +139,18 @@ export function CoinbasePopup() {
);
}

const isCompleted = status === CoinbaseOnrampStatus.Completed;
const isFailed = status === CoinbaseOnrampStatus.Failed;

return (
<div className="flex flex-col h-screen bg-[#0F1410]">
{/* Status bar */}
{(isCompleted || isFailed) && (
{(completed || failed) && (
<div
className={`flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium ${
isCompleted
completed
? "bg-[#1a2e1a] text-[#4ade80]"
: "bg-[#2e1a1a] text-[#f87171]"
}`}
>
{isCompleted ? (
{completed ? (
<>
<span>✓</span>
<span>Payment successful! This window will close shortly.</span>
Expand All @@ -125,9 +164,17 @@ export function CoinbasePopup() {
</div>
)}

{/* Committed indicator (payment in progress) */}
{committed && !completed && !failed && (
<div className="flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium bg-[#1a2a2e] text-[#60a5fa]">
<SpinnerIcon className="animate-spin" size="sm" />
<span>Payment processing...</span>
</div>
)}

{/* Iframe */}
<div className="flex-1 relative">
{!iframeLoaded && (
{!iframeReady && (
<div className="absolute inset-0 flex items-center justify-center bg-[#0F1410] z-10">
<SpinnerIcon className="animate-spin" size="lg" />
</div>
Expand All @@ -136,8 +183,10 @@ export function CoinbasePopup() {
src={paymentLink}
className="h-full w-full border-none"
allow="payment"
sandbox="allow-scripts allow-same-origin"
referrerPolicy="no-referrer"
title="Coinbase Onramp"
onLoad={() => setIframeLoaded(true)}
onLoad={() => setIframeReady(true)}
/>
</div>
</div>
Expand Down
Loading