diff --git a/src/fund/constants.ts b/src/fund/constants.ts index d2d3a9f2a8..9e5e4648b5 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -1,5 +1,6 @@ +export const DEFAULT_ONRAMP_URL = 'https://pay.coinbase.com'; // The base URL for the Coinbase Onramp widget. -export const ONRAMP_BUY_URL = 'https://pay.coinbase.com/buy'; +export const ONRAMP_BUY_URL = `${DEFAULT_ONRAMP_URL}/buy`; // The recommended height of a Coinbase Onramp popup window. export const ONRAMP_POPUP_HEIGHT = 720; // The recommended width of a Coinbase Onramp popup window. diff --git a/src/fund/index.ts b/src/fund/index.ts index 88e1fecbc2..a01e7248d8 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -1,6 +1,8 @@ export { FundButton } from './components/FundButton'; export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; +export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; + export type { GetOnrampUrlWithProjectIdParams, GetOnrampUrlWithSessionTokenParams, diff --git a/src/fund/types.ts b/src/fund/types.ts index ea01fad030..84ab23b55d 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -118,3 +118,72 @@ export type FundButtonReact = { rel?: string; // Specifies the relationship between the current document and the linked document target?: string; // Where to open the target if `openIn` is set to tab }; + +/** + * Matches a JSON object. + * This type can be useful to enforce some input to be JSON-compatible or as a super-type to be extended from. Don't use this as a direct return type as the user would have to double-cast it: `jsonObject as unknown as CustomResponse`. Instead, you could extend your CustomResponse type from it to ensure your type only uses JSON-compatible types: `interface CustomResponse extends JsonObject { … }`. + * @category JSON + */ +export type JsonObject = { [Key in string]?: JsonValue }; + +/** + * Matches a JSON array. + * @category JSON + */ +export type JsonArray = JsonValue[]; + +/** + * Matches any valid JSON primitive value. + * @category JSON + */ +export type JsonPrimitive = string | number | boolean | null; + +/** + * Matches any valid JSON value. + * @see `Jsonify` if you need to transform a type to one that is assignable to `JsonValue`. + * @category JSON + */ +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +export type OpenEvent = { + eventName: 'open'; + widgetName: string; +}; + +export type TransitionViewEvent = { + eventName: 'transition_view'; + pageRoute: string; +}; + +export type PublicErrorEvent = { + eventName: 'error'; + error: OnRampError; +}; + +export type ExitEvent = { + eventName: 'exit'; + error?: OnRampError; +}; + +export type SuccessEvent = { + eventName: 'success'; +}; + +export type RequestOpenUrlEvent = { + eventName: 'request_open_url'; + url: string; +}; + +export type EventMetadata = + | OpenEvent + | TransitionViewEvent + | PublicErrorEvent + | ExitEvent + | SuccessEvent + | RequestOpenUrlEvent; + +export type OnRampError = { + errorType: 'internal_error' | 'handled_error' | 'network_error'; + code?: string; + debugMessage?: string; +}; diff --git a/src/fund/utils/setupOnrampEventListeners.test.ts b/src/fund/utils/setupOnrampEventListeners.test.ts new file mode 100644 index 0000000000..8b08f23aa1 --- /dev/null +++ b/src/fund/utils/setupOnrampEventListeners.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EventMetadata } from '../types'; +import { setupOnrampEventListeners } from './setupOnrampEventListeners'; +import { subscribeToWindowMessage } from './subscribeToWindowMessage'; + +vi.mock('./subscribeToWindowMessage', () => ({ + subscribeToWindowMessage: vi.fn(), +})); + +describe('setupOnrampEventListeners', () => { + let unsubscribe: ReturnType; + + beforeEach(() => { + unsubscribe = vi.fn(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should call subscribeToWindowMessage with correct parameters', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + expect(subscribeToWindowMessage).toHaveBeenCalledWith({ + allowedOrigin: host, + onMessage: expect.any(Function), + }); + }); + + it('should call onSuccess callback when success event is received', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + const eventMetadata: EventMetadata = { eventName: 'success' }; + + vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( + eventMetadata, + ); + + expect(onSuccess).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith(eventMetadata); + }); + + it('should call onExit callback when exit event is received', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + const eventMetadata: EventMetadata = { + eventName: 'exit', + error: { + errorType: 'internal_error', + }, + }; + vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( + eventMetadata, + ); + + expect(onExit).toHaveBeenCalledWith(eventMetadata.error); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith(eventMetadata); + }); + + it('should call onEvent callback for any event received', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + const eventMetadata: EventMetadata = { eventName: 'success' }; + vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( + eventMetadata, + ); + + expect(onEvent).toHaveBeenCalledWith(eventMetadata); + }); + + it('should return the unsubscribe function', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + vi.mocked(subscribeToWindowMessage).mockReturnValue(unsubscribe); + + const result = setupOnrampEventListeners({ + onEvent, + onExit, + onSuccess, + host, + }); + + expect(result).toBe(unsubscribe); + }); +}); diff --git a/src/fund/utils/setupOnrampEventListeners.ts b/src/fund/utils/setupOnrampEventListeners.ts new file mode 100644 index 0000000000..98a9a093fe --- /dev/null +++ b/src/fund/utils/setupOnrampEventListeners.ts @@ -0,0 +1,41 @@ +import { DEFAULT_ONRAMP_URL } from '../constants'; +import type { EventMetadata, OnRampError } from '../types'; +import { subscribeToWindowMessage } from './subscribeToWindowMessage'; + +type SetupOnrampEventListenersParams = { + host?: string; + onSuccess?: () => void; + onExit?: (error?: OnRampError) => void; + onEvent?: (event: EventMetadata) => void; +}; + +/** + * Subscribes to events from the Coinbase Onramp widget. + * @param onEvent - Callback for when any event is received. + * @param onExit - Callback for when an exit event is received. + * @param onSuccess - Callback for when a success event is received. + * @returns a function to unsubscribe from the event listener. + */ +export function setupOnrampEventListeners({ + onEvent, + onExit, + onSuccess, + host = DEFAULT_ONRAMP_URL, +}: SetupOnrampEventListenersParams) { + const unsubscribe = subscribeToWindowMessage({ + allowedOrigin: host, + onMessage: (data) => { + const metadata = data as EventMetadata; + + if (metadata.eventName === 'success') { + onSuccess?.(); + } + if (metadata.eventName === 'exit') { + onExit?.(metadata.error); + } + onEvent?.(metadata); + }, + }); + + return unsubscribe; +} diff --git a/src/fund/utils/subscribeToWindowMessage.test.ts b/src/fund/utils/subscribeToWindowMessage.test.ts new file mode 100644 index 0000000000..2f3be349dc --- /dev/null +++ b/src/fund/utils/subscribeToWindowMessage.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MessageCodes, + subscribeToWindowMessage, +} from './subscribeToWindowMessage'; + +describe('subscribeToWindowMessage', () => { + let unsubscribe: () => void; + const DEFAULT_ORIGIN = 'https://default.origin'; + // biome-ignore lint/suspicious/noExplicitAny: + const mockMessageEvent = (data: any, origin = DEFAULT_ORIGIN) => + new MessageEvent('message', { data, origin }); + + beforeEach(() => { + unsubscribe = () => {}; + }); + + afterEach(() => { + unsubscribe(); + }); + + it('should subscribe to window message and call onMessage when message is received', async () => { + const onMessage = vi.fn(); + unsubscribe = subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onMessage).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('should not call onMessage if the origin is not allowed', async () => { + const onMessage = vi.fn(); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: 'https://not.allowed.origin', + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('should validate the origin using onValidateOrigin callback', async () => { + const onMessage = vi.fn(); + const onValidateOrigin = vi.fn().mockResolvedValue(true); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + onValidateOrigin, + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onValidateOrigin).toHaveBeenCalledWith(DEFAULT_ORIGIN); + expect(onMessage).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('should not call onMessage if onValidateOrigin returns false', async () => { + const onMessage = vi.fn(); + const onValidateOrigin = vi.fn().mockResolvedValue(false); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + onValidateOrigin, + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onValidateOrigin).toHaveBeenCalledWith(DEFAULT_ORIGIN); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('should not call onMessage if the message code is not "event"', async () => { + const onMessage = vi.fn(); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + }); + + const event = mockMessageEvent({ + eventName: 'not-event', + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/fund/utils/subscribeToWindowMessage.ts b/src/fund/utils/subscribeToWindowMessage.ts new file mode 100644 index 0000000000..55edd8edaf --- /dev/null +++ b/src/fund/utils/subscribeToWindowMessage.ts @@ -0,0 +1,72 @@ +import { DEFAULT_ONRAMP_URL } from '../constants'; +import type { JsonObject } from '../types'; + +export enum MessageCodes { + AppParams = 'app_params', + PaymentLinkSuccess = 'payment_link_success', + PaymentLinkClosed = 'payment_link_closed', + GuestCheckoutRedirectSuccess = 'guest_checkout_redirect_success', + Success = 'success', + Event = 'event', +} + +type MessageCode = `${MessageCodes}`; + +type MessageData = JsonObject; + +type PostMessageData = { + eventName: MessageCode; + data?: MessageData; +}; + +/** + * Subscribes to a message from the parent window. + * @param messageCode A message code to subscribe to. + * @param onMessage Callback for when the message is received. + * @param allowedOrigin The origin to allow messages from. + * @param onValidateOrigin Callback to validate the origin of the message. + * @returns + */ +export function subscribeToWindowMessage({ + onMessage, + allowedOrigin = DEFAULT_ONRAMP_URL, + onValidateOrigin = () => Promise.resolve(true), +}: { + onMessage: (data?: MessageData) => void; + allowedOrigin: string; + onValidateOrigin?: (origin: string) => Promise; +}) { + const handleMessage = (event: MessageEvent) => { + if (!isAllowedOrigin({ event, allowedOrigin })) { + return; + } + + const { eventName, data } = event.data; + + if (eventName === 'event') { + (async () => { + if (await onValidateOrigin(event.origin)) { + onMessage(data); + } + })(); + } + }; + + window.addEventListener('message', handleMessage); + + // Unsubscribe + return () => { + window.removeEventListener('message', handleMessage); + }; +} + +function isAllowedOrigin({ + event, + allowedOrigin, +}: { + event: MessageEvent; + allowedOrigin: string; +}) { + const isOriginAllowed = !allowedOrigin || event.origin === allowedOrigin; + return isOriginAllowed; +}