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
76 changes: 69 additions & 7 deletions packages/keychain/src/components/coinbase-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ type CoinbaseEventName =
| "onramp_api.load_pending"
| "onramp_api.load_success"
| "onramp_api.load_error"
| "onramp_api.pending_payment_auth"
| "onramp_api.payment_authorized"
| "onramp_api.apple_pay_button_pressed"
| "onramp_api.commit_success"
| "onramp_api.commit_error"
| "onramp_api.cancel"
Expand All @@ -25,6 +28,42 @@ interface CoinbasePostMessage {
};
}

const parseCoinbaseMessage = (rawData: unknown): CoinbasePostMessage | null => {
if (!rawData) return null;

// Some environments send Coinbase payloads as nested JSON strings.
// Parse up to a bounded depth to avoid infinite loops on malformed input.
let data: unknown = rawData;
for (let i = 0; i < 5; i++) {
if (typeof data !== "string") break;
try {
data = JSON.parse(data);
} catch {
// Last-resort fallback: extract eventName from raw string payloads
// that are not valid JSON due to transport quirks.
const rawString = String(data);
const eventNameMatch = rawString.match(/"eventName"\s*:\s*"([^"]+)"/);
if (eventNameMatch?.[1]) {
return { eventName: eventNameMatch[1] as CoinbaseEventName };
}
return null;
}
}

if (typeof data === "object" && data !== null) {
// Some message bridges wrap payloads under a `data` key.
const wrappedData = (data as { data?: unknown }).data;
if (wrappedData) {
const nested = parseCoinbaseMessage(wrappedData);
if (nested) return nested;
}

return data as CoinbasePostMessage;
}

return null;
};

/**
* Standalone page rendered at /coinbase in the keychain app.
* Opened as a popup from the CoinbaseCheckout component.
Expand Down Expand Up @@ -52,9 +91,14 @@ export function CoinbasePopup() {
useEffect(() => {
// Derive the allowed origin from the payment link URL
const allowedOrigin = paymentLink ? new URL(paymentLink).origin : null;
console.log("[coinbase-popup] Listening for postMessages, allowedOrigin:", allowedOrigin);
console.log(
"[coinbase-popup] Listening for postMessages, allowedOrigin:",
allowedOrigin,
);
console.log("[coinbase-popup] paymentLink:", paymentLink);
console.log("[coinbase-popup] sandbox attrs: allow-scripts allow-same-origin");
console.log(
"[coinbase-popup] sandbox attrs: allow-scripts allow-same-origin",
);

const handleMessage = (event: MessageEvent) => {
// Log ALL incoming messages for debugging
Expand All @@ -73,14 +117,21 @@ export function CoinbasePopup() {
return;
}

// Only process messages that look like Coinbase events
const data = event.data as CoinbasePostMessage;
// Coinbase may send object payloads or JSON-encoded strings.
const data = parseCoinbaseMessage(event.data);
if (!data?.eventName?.startsWith("onramp_api.")) {
console.log("[coinbase-popup] Ignoring non-Coinbase message:", event.data);
console.log(
"[coinbase-popup] Ignoring non-Coinbase message:",
event.data,
);
return;
}

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

switch (data.eventName) {
case "onramp_api.load_success":
Expand All @@ -100,6 +151,18 @@ export function CoinbasePopup() {
setCommitted(true);
break;

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

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

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

case "onramp_api.commit_error":
setError(
data.data?.errorMessage ||
Expand Down Expand Up @@ -202,7 +265,6 @@ export function CoinbasePopup() {
title="Coinbase Onramp"
onLoad={() => {
console.log("[coinbase-popup] iframe onLoad fired");
setIframeReady(true);
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ import { CoinbaseOnrampStatus } from "@cartridge/ui/utils/api/cartridge";
export function CoinbaseCheckout() {
const {
paymentLink,
isCreatingOrder,
orderStatus,
onCreateCoinbaseOrder,
openPaymentPopup,
stopPolling,
} = useOnchainPurchaseContext();
const { navigate } = useNavigation();
const [showPolicies, setShowPolicies] = useState(true);
const [popupOpened, setPopupOpened] = useState(false);
const [isOpeningPopup, setIsOpeningPopup] = useState(false);

// Create the order if we don't have a payment link yet
useEffect(() => {
Expand All @@ -47,14 +48,28 @@ export function CoinbaseCheckout() {
};
}, [stopPolling]);

const handleContinue = useCallback(() => {
const handleContinue = useCallback(async () => {
if (isOpeningPopup) return;

setShowPolicies(false);
// Open popup immediately after accepting policies
if (paymentLink && !popupOpened) {
openPaymentPopup();
setPopupOpened(true);
setIsOpeningPopup(true);
try {
const order = await onCreateCoinbaseOrder({ force: true });
const nextPaymentLink = order?.coinbaseOrder.paymentLink ?? paymentLink;
const nextOrderId = order?.coinbaseOrder.orderId;

if (nextPaymentLink && nextOrderId) {
openPaymentPopup({
paymentLink: nextPaymentLink,
orderId: nextOrderId,
});
}
} catch {
setShowPolicies(true);
} finally {
setIsOpeningPopup(false);
}
}, [paymentLink, popupOpened, openPaymentPopup]);
}, [isOpeningPopup, onCreateCoinbaseOrder, paymentLink, openPaymentPopup]);

const isFailed = orderStatus === CoinbaseOnrampStatus.Failed;

Expand Down Expand Up @@ -96,9 +111,9 @@ export function CoinbaseCheckout() {
<Button
className="w-full"
onClick={handleContinue}
disabled={!paymentLink}
disabled={isCreatingOrder || isOpeningPopup}
>
{paymentLink ? "CONTINUE" : "LOADING..."}
{isCreatingOrder || isOpeningPopup ? "LOADING..." : "CONTINUE"}
</Button>
</LayoutFooter>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const MockOnchainPurchaseProvider = ({ children }: { children: ReactNode }) => {
coinbaseQuote: undefined,
isFetchingCoinbaseQuote: false,
onApplePaySelect: () => {},
onCreateCoinbaseOrder: async () => {},
onCreateCoinbaseOrder: async () => undefined,
openPaymentPopup: () => {},
stopPolling: () => {},
orderId: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const mockOnchainPurchaseValue: OnchainPurchaseContextType = {
coinbaseQuote: undefined,
isFetchingCoinbaseQuote: false,
onApplePaySelect: () => {},
onCreateCoinbaseOrder: async () => {},
onCreateCoinbaseOrder: async () => undefined,
openPaymentPopup: () => {},
stopPolling: () => {},
orderId: undefined,
Expand Down
43 changes: 25 additions & 18 deletions packages/keychain/src/context/starterpack/onchain-purchase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
useTokenSelection,
useCoinbase,
type TokenOption,
type CoinbaseOrderResult,
type CoinbaseTransactionResult,
type CoinbaseQuoteResult,
} from "@/hooks/starterpack";
Expand Down Expand Up @@ -99,8 +100,10 @@ export interface OnchainPurchaseContextType {
onSendDeposit: () => Promise<void>;
waitForDeposit: (swapId: string) => Promise<boolean>;
onApplePaySelect: () => void;
onCreateCoinbaseOrder: () => Promise<void>;
openPaymentPopup: () => void;
onCreateCoinbaseOrder: (opts?: {
force?: boolean;
}) => Promise<CoinbaseOrderResult | undefined>;
openPaymentPopup: (opts?: { paymentLink?: string; orderId?: string }) => void;
stopPolling: () => void;
getTransactions: (username: string) => Promise<CoinbaseTransactionResult[]>;
}
Expand Down Expand Up @@ -487,25 +490,29 @@ export const OnchainPurchaseProvider = ({
clearSelectedWalletInternal();
}, [clearSelectedWalletInternal]);

const onCreateCoinbaseOrder = useCallback(async () => {
if (!onchainDetails?.quote) {
throw new Error("Quote not loaded yet");
}
const onCreateCoinbaseOrder = useCallback(
async (opts?: { force?: boolean }) => {
if (!onchainDetails?.quote) {
throw new Error("Quote not loaded yet");
}

if (isCreatingOrder || paymentLink) return;
const force = opts?.force ?? false;
if (isCreatingOrder || (paymentLink && !force)) return;

const purchaseAmount = onchainDetails.quote.totalCost * BigInt(quantity);
const purchaseAmount = onchainDetails.quote.totalCost * BigInt(quantity);

await createCoinbaseOrder({
purchaseUSDCAmount: (Number(purchaseAmount) / 1_000_000).toString(),
});
}, [
onchainDetails,
quantity,
isCreatingOrder,
paymentLink,
createCoinbaseOrder,
]);
return createCoinbaseOrder({
purchaseUSDCAmount: (Number(purchaseAmount) / 1_000_000).toString(),
});
},
[
onchainDetails,
quantity,
isCreatingOrder,
paymentLink,
createCoinbaseOrder,
],
);

const contextValue: OnchainPurchaseContextType = {
purchaseItems,
Expand Down
79 changes: 42 additions & 37 deletions packages/keychain/src/hooks/starterpack/coinbase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export interface UseCoinbaseReturn {
createOrder: (input: CreateOrderInput) => Promise<CoinbaseOrderResult>;
getTransactions: (username: string) => Promise<CoinbaseTransactionResult[]>;
getQuote: (input: CoinbaseQuoteInput) => Promise<CoinbaseQuoteResult>;
openPaymentPopup: () => void;
openPaymentPopup: (opts?: { paymentLink?: string; orderId?: string }) => void;
stopPolling: () => void;
}

Expand Down Expand Up @@ -212,42 +212,47 @@ export function useCoinbase({
);

/** Open the payment link in a popup and begin polling */
const openPaymentPopup = useCallback(() => {
if (!paymentLink || !orderId) return;

// Build the keychain-hosted coinbase page URL
// The popup runs at the keychain origin (x.cartridge.gg) so the
// Coinbase iframe inside it will work correctly.
const keychainOrigin = window.location.origin;
const popupUrl = new URL("/coinbase", keychainOrigin);
popupUrl.searchParams.set("paymentLink", paymentLink);
popupUrl.searchParams.set("orderId", orderId);

// Open a centered popup (use screen dimensions since we may be in an iframe)
const width = 500;
const height = 700;
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;

const popup = window.open(
popupUrl.toString(),
"coinbase-payment",
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=yes,resizable=yes`,
);

popupRef.current = popup;

// Start polling for order status in the keychain as well
startPolling(orderId);

// Watch for the popup being closed by the user
const checkClosed = setInterval(() => {
if (popup && popup.closed) {
clearInterval(checkClosed);
// Keep polling briefly to catch last-second completions
}
}, 1000);
}, [paymentLink, orderId, startPolling]);
const openPaymentPopup = useCallback(
(opts?: { paymentLink?: string; orderId?: string }) => {
const targetPaymentLink = opts?.paymentLink ?? paymentLink;
const targetOrderId = opts?.orderId ?? orderId;
if (!targetPaymentLink || !targetOrderId) return;

// Build the keychain-hosted coinbase page URL
// The popup runs at the keychain origin (x.cartridge.gg) so the
// Coinbase iframe inside it will work correctly.
const keychainOrigin = window.location.origin;
const popupUrl = new URL("/coinbase", keychainOrigin);
popupUrl.searchParams.set("paymentLink", targetPaymentLink);
popupUrl.searchParams.set("orderId", targetOrderId);

// Open a centered popup (use screen dimensions since we may be in an iframe)
const width = 500;
const height = 700;
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;

const popup = window.open(
popupUrl.toString(),
"coinbase-payment",
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=yes,resizable=yes`,
);

popupRef.current = popup;

// Start polling for order status in the keychain as well
startPolling(targetOrderId);

// Watch for the popup being closed by the user
const checkClosed = setInterval(() => {
if (popup && popup.closed) {
clearInterval(checkClosed);
// Keep polling briefly to catch last-second completions
}
}, 1000);
},
[paymentLink, orderId, startPolling],
);

const createOrder = useCallback(
async (input: CreateOrderInput) => {
Expand Down
Loading