From 9fe2fdeba1a265b4d86a3eaa12e252dc496a9808 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Thu, 14 Nov 2024 13:29:41 -0800 Subject: [PATCH 1/4] Onramp - event subscriptions migration --- src/fund/constants.ts | 3 +- src/fund/index.ts | 2 + src/fund/types.ts | 65 ++++++++++ .../utils/setupOnrampEventListeners.test.ts | 108 ++++++++++++++++ src/fund/utils/setupOnrampEventListeners.ts | 42 ++++++ .../utils/subscribeToWindowMessage.test.ts | 122 ++++++++++++++++++ src/fund/utils/subscribeToWindowMessage.ts | 72 +++++++++++ 7 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 src/fund/utils/setupOnrampEventListeners.test.ts create mode 100644 src/fund/utils/setupOnrampEventListeners.ts create mode 100644 src/fund/utils/subscribeToWindowMessage.test.ts create mode 100644 src/fund/utils/subscribeToWindowMessage.ts 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..60e3bb2e55 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -118,3 +118,68 @@ 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'; + // biome-ignore lint/suspicious/noExplicitAny: + error: any; +}; + +export type ExitEvent = { + eventName: 'exit'; + // biome-ignore lint/suspicious/noExplicitAny: + error?: any; +}; + +export type SuccessEvent = { + eventName: 'success'; +}; + +export type RequestOpenUrlEvent = { + eventName: 'request_open_url'; + url: string; +}; + +export type EventMetadata = + | OpenEvent + | TransitionViewEvent + | PublicErrorEvent + | ExitEvent + | SuccessEvent + | RequestOpenUrlEvent; diff --git a/src/fund/utils/setupOnrampEventListeners.test.ts b/src/fund/utils/setupOnrampEventListeners.test.ts new file mode 100644 index 0000000000..9f563778ab --- /dev/null +++ b/src/fund/utils/setupOnrampEventListeners.test.ts @@ -0,0 +1,108 @@ +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: 'some error', + }; + vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( + eventMetadata, + ); + + expect(onExit).toHaveBeenCalledWith('some 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..e83bacf676 --- /dev/null +++ b/src/fund/utils/setupOnrampEventListeners.ts @@ -0,0 +1,42 @@ +import { DEFAULT_ONRAMP_URL } from '../constants'; +import type { EventMetadata } from '../types'; +import { subscribeToWindowMessage } from './subscribeToWindowMessage'; + +type SetupOnrampEventListenersParams = { + host?: string; + onSuccess?: () => void; + // biome-ignore lint/suspicious/noExplicitAny: + onExit?: (error?: any) => 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..c77ed0a238 --- /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', +} + +export type MessageCode = `${MessageCodes}`; + +export type MessageData = JsonObject; + +export 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; +} From 49e3e203a8bfeba45ba5259a3750c20663cbdbb3 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 15 Nov 2024 12:23:27 -0800 Subject: [PATCH 2/4] Address review comments --- src/fund/types.ts | 12 ++++++++---- src/fund/utils/setupOnrampEventListeners.test.ts | 6 ++++-- src/fund/utils/setupOnrampEventListeners.ts | 5 ++--- src/fund/utils/subscribeToWindowMessage.ts | 6 +++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/fund/types.ts b/src/fund/types.ts index 60e3bb2e55..44fa817c52 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -157,14 +157,12 @@ export type TransitionViewEvent = { export type PublicErrorEvent = { eventName: 'error'; - // biome-ignore lint/suspicious/noExplicitAny: - error: any; + error: OnRampError; }; export type ExitEvent = { eventName: 'exit'; - // biome-ignore lint/suspicious/noExplicitAny: - error?: any; + error?: OnRampError; }; export type SuccessEvent = { @@ -183,3 +181,9 @@ export type EventMetadata = | ExitEvent | SuccessEvent | RequestOpenUrlEvent; + +export type OnRampError = { + errorType: 'internal_error' | 'handled_error' | 'network_error'; + code?: string; + debugMessage?: string; +} \ No newline at end of file diff --git a/src/fund/utils/setupOnrampEventListeners.test.ts b/src/fund/utils/setupOnrampEventListeners.test.ts index 9f563778ab..8b08f23aa1 100644 --- a/src/fund/utils/setupOnrampEventListeners.test.ts +++ b/src/fund/utils/setupOnrampEventListeners.test.ts @@ -61,13 +61,15 @@ describe('setupOnrampEventListeners', () => { const eventMetadata: EventMetadata = { eventName: 'exit', - error: 'some error', + error: { + errorType: 'internal_error', + }, }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( eventMetadata, ); - expect(onExit).toHaveBeenCalledWith('some error'); + expect(onExit).toHaveBeenCalledWith(eventMetadata.error); expect(onSuccess).not.toHaveBeenCalled(); expect(onEvent).toHaveBeenCalledWith(eventMetadata); }); diff --git a/src/fund/utils/setupOnrampEventListeners.ts b/src/fund/utils/setupOnrampEventListeners.ts index e83bacf676..98a9a093fe 100644 --- a/src/fund/utils/setupOnrampEventListeners.ts +++ b/src/fund/utils/setupOnrampEventListeners.ts @@ -1,12 +1,11 @@ import { DEFAULT_ONRAMP_URL } from '../constants'; -import type { EventMetadata } from '../types'; +import type { EventMetadata, OnRampError } from '../types'; import { subscribeToWindowMessage } from './subscribeToWindowMessage'; type SetupOnrampEventListenersParams = { host?: string; onSuccess?: () => void; - // biome-ignore lint/suspicious/noExplicitAny: - onExit?: (error?: any) => void; + onExit?: (error?: OnRampError) => void; onEvent?: (event: EventMetadata) => void; }; diff --git a/src/fund/utils/subscribeToWindowMessage.ts b/src/fund/utils/subscribeToWindowMessage.ts index c77ed0a238..55edd8edaf 100644 --- a/src/fund/utils/subscribeToWindowMessage.ts +++ b/src/fund/utils/subscribeToWindowMessage.ts @@ -10,11 +10,11 @@ export enum MessageCodes { Event = 'event', } -export type MessageCode = `${MessageCodes}`; +type MessageCode = `${MessageCodes}`; -export type MessageData = JsonObject; +type MessageData = JsonObject; -export type PostMessageData = { +type PostMessageData = { eventName: MessageCode; data?: MessageData; }; From 5ff47c214244f60eb49f9806f96ff3e5877c0422 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 15 Nov 2024 12:26:43 -0800 Subject: [PATCH 3/4] Formatting --- src/fund/types.ts | 2 +- src/fund/utils/setupOnrampEventListeners.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fund/types.ts b/src/fund/types.ts index 44fa817c52..84ab23b55d 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -186,4 +186,4 @@ export type OnRampError = { errorType: 'internal_error' | 'handled_error' | 'network_error'; code?: string; debugMessage?: string; -} \ No newline at end of file +}; diff --git a/src/fund/utils/setupOnrampEventListeners.test.ts b/src/fund/utils/setupOnrampEventListeners.test.ts index 8b08f23aa1..8e76ec565d 100644 --- a/src/fund/utils/setupOnrampEventListeners.test.ts +++ b/src/fund/utils/setupOnrampEventListeners.test.ts @@ -43,7 +43,7 @@ describe('setupOnrampEventListeners', () => { const eventMetadata: EventMetadata = { eventName: 'success' }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata, + eventMetadata ); expect(onSuccess).toHaveBeenCalled(); @@ -66,7 +66,7 @@ describe('setupOnrampEventListeners', () => { }, }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata, + eventMetadata ); expect(onExit).toHaveBeenCalledWith(eventMetadata.error); @@ -84,7 +84,7 @@ describe('setupOnrampEventListeners', () => { const eventMetadata: EventMetadata = { eventName: 'success' }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata, + eventMetadata ); expect(onEvent).toHaveBeenCalledWith(eventMetadata); From 5ea056e9192e4440920780596246afc907da70a8 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 15 Nov 2024 13:06:46 -0800 Subject: [PATCH 4/4] Fix formatting --- src/fund/utils/setupOnrampEventListeners.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fund/utils/setupOnrampEventListeners.test.ts b/src/fund/utils/setupOnrampEventListeners.test.ts index 8e76ec565d..8b08f23aa1 100644 --- a/src/fund/utils/setupOnrampEventListeners.test.ts +++ b/src/fund/utils/setupOnrampEventListeners.test.ts @@ -43,7 +43,7 @@ describe('setupOnrampEventListeners', () => { const eventMetadata: EventMetadata = { eventName: 'success' }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata + eventMetadata, ); expect(onSuccess).toHaveBeenCalled(); @@ -66,7 +66,7 @@ describe('setupOnrampEventListeners', () => { }, }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata + eventMetadata, ); expect(onExit).toHaveBeenCalledWith(eventMetadata.error); @@ -84,7 +84,7 @@ describe('setupOnrampEventListeners', () => { const eventMetadata: EventMetadata = { eventName: 'success' }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata + eventMetadata, ); expect(onEvent).toHaveBeenCalledWith(eventMetadata);