Skip to content

Commit

Permalink
Add Offramp support (#302)
Browse files Browse the repository at this point in the history
* generate offramp url

* initOffRamp

* fix vulnerability

* change param name

* change path to sell

* add sessionToken

* add test

* lint

* fix vulnerability

* revert

* make aggregator params optional for now

* fix comment
  • Loading branch information
sumxu96 authored Oct 18, 2024
1 parent a206100 commit cb2f991
Show file tree
Hide file tree
Showing 10 changed files with 467 additions and 89 deletions.
110 changes: 110 additions & 0 deletions src/offramp/generateOffRampURL.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { generateOffRampURL } from './generateOffRampURL';

describe('generateOffRampURL', () => {
it('generates URL with expected default parameters', () => {
const url = new URL(
generateOffRampURL({
appId: 'test',
}),
);

expect(url.origin).toEqual('https://pay.coinbase.com');
expect(url.pathname).toEqual('/v3/sell/input');
expect(url.searchParams.get('appId')).toEqual('test');
});

it('should support redirectUrl', () => {
const url = new URL(
generateOffRampURL({
appId: 'test',
redirectUrl: 'https://example.com',
}),
);

expect(url.searchParams.get('redirectUrl')).toEqual('https://example.com');
});

it('generates URL with multiple addresses', () => {
const addresses = {
'0x1': ['base', 'ethereum'],
'123abc': ['solana'],
};

const url = new URL(
generateOffRampURL({
appId: 'test',
addresses,
redirectUrl: 'https://example.com',
}),
);

expect(url.searchParams.get('addresses')).toEqual(
'{"0x1":["base","ethereum"],"123abc":["solana"]}',
);
});

it('generates URL with multiple addresses and assets', () => {
const url = new URL(
generateOffRampURL({
appId: 'test',
addresses: {
'0x5ome4ddre55': ['ethereum', 'avalanche-c-chain'],
'90123jd09ef09df': ['solana'],
},
assets: ['USDC', 'SOL'],
}),
);

expect(url.searchParams.get('addresses')).toEqual(
`{\"0x5ome4ddre55\":[\"ethereum\",\"avalanche-c-chain\"],\"90123jd09ef09df\":[\"solana\"]}`,
);
expect(url.searchParams.get('assets')).toEqual('["USDC","SOL"]');
});

it('should support dynamic host', () => {
const url = new URL(
generateOffRampURL({
host: 'http://localhost:3000',
appId: 'test',
}),
);

expect(url.origin).toEqual('http://localhost:3000');
expect(url.pathname).toEqual('/v3/sell/input');
expect(url.searchParams.get('appId')).toEqual('test');
});

it('should support preset amounts', () => {
const url = new URL(
generateOffRampURL({
appId: 'test',
presetCryptoAmount: 0.1,
presetFiatAmount: 20,
}),
);

expect(url.searchParams.get('presetFiatAmount')).toEqual('20');
expect(url.searchParams.get('presetCryptoAmount')).toEqual('0.1');
});

it('should support defaultNetwork', () => {
const url = new URL(
generateOffRampURL({
appId: 'test',
defaultNetwork: 'ethereum',
}),
);
expect(url.searchParams.get('defaultNetwork')).toEqual('ethereum');
});

it('should support sessionToken', () => {
const url = new URL(
generateOffRampURL({
sessionToken: 'test',
}),
);
expect(url.origin).toEqual('https://pay.coinbase.com');
expect(url.pathname).toEqual('/v3/sell/input');
expect(url.searchParams.get('sessionToken')).toEqual('test');
});
});
35 changes: 35 additions & 0 deletions src/offramp/generateOffRampURL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { OffRampAppParams } from 'types/offramp';
import { DEFAULT_HOST } from '../config';
import type { Theme } from '../types/widget';

export type GenerateOffRampURLOptions = {
/** This & addresses or sessionToken are required. */
appId?: string;
host?: string;
theme?: Theme;
/** This or appId & addresses are required. */
sessionToken?: string;
} & OffRampAppParams;

export const generateOffRampURL = ({
host = DEFAULT_HOST,
...props
}: GenerateOffRampURLOptions): string => {
const url = new URL(host);
url.pathname = '/v3/sell/input';

(Object.keys(props) as (keyof typeof props)[]).forEach((key) => {
const value = props[key];
if (value !== undefined) {
if (['string', 'number', 'boolean'].includes(typeof value)) {
url.searchParams.append(key, value.toString());
} else {
url.searchParams.append(key, JSON.stringify(value));
}
}
});

url.searchParams.sort();

return url.toString();
};
25 changes: 25 additions & 0 deletions src/offramp/initOffRamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { initOffRamp } from './initOffRamp';
import { CBPayInstance } from '../utils/CBPayInstance';

jest.mock('../utils/CBPayInstance');

describe('initOffRamp', () => {
it('should return CBPayInstance', async () => {
let instance: unknown;
initOffRamp(
{
experienceLoggedIn: 'popup',
experienceLoggedOut: 'popup',
appId: 'abc123',
widgetParameters: { addresses: { '0x1': ['base'] }, redirectUrl: 'https://example.com' },
},
(_, newInstance) => {
instance = newInstance;
},
);

expect(CBPayInstance).toHaveBeenCalledTimes(1);

expect(instance instanceof CBPayInstance).toBe(true);
});
});
27 changes: 27 additions & 0 deletions src/offramp/initOffRamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { CBPayExperienceOptions } from '../types/widget';
import { CBPayInstance, CBPayInstanceType } from '../utils/CBPayInstance';
import { OffRampAppParams } from '../types/offramp';

export type InitOffRampParams = CBPayExperienceOptions<OffRampAppParams>;

export type InitOffRampCallback = {
(error: Error, instance: null): void;
(error: null, instance: CBPayInstanceType): void;
};

export const initOffRamp = (
{
experienceLoggedIn = 'new_tab', // default experience type
widgetParameters,
...options
}: InitOffRampParams,
callback: InitOffRampCallback,
): void => {
const instance = new CBPayInstance({
...options,
widget: 'sell',
experienceLoggedIn,
appParams: widgetParameters,
});
callback(null, instance);
};
55 changes: 55 additions & 0 deletions src/types/offramp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export type BaseOffRampAppParams = {
/**
*
* Each entry in the record represents a wallet address and the networks it is valid for. There should only be a
* single address for each network your app supports. Users will be able to cash out any owned assets supported by any of
* the networks you specify. See the assets parameter if you want to restrict the available assets.
*
* Example:
*
* Show all assets users have on the base network, only on the base network:
*
* `{ "0x1": ["base"] }`
*
*/
addresses?: Record<string, string[]>;
/** A URL that the user will be redirected to after to sign their transaction after the transaction has been committed. */
redirectUrl?: string;
/**
* This optional parameter will restrict the assets available for the user to cash out. It acts as a filter on the
* networks specified in the {addresses} param.
*
* Example:
*
* Support only USDC on either the base network or the ethereum network:
*
* `addresses: { "0x1": ["base", "ethereum"] }, assets: ["USDC"]`
*
*/
assets?: string[];
/** The default network that should be selected when multiple networks are present. */
defaultNetwork?: string;
/** The preset input amount as a crypto value. i.e. 0.1 ETH. */
presetCryptoAmount?: number;
/**
* The preset input amount as a fiat value. i.e. 15 USD.
* Ignored if presetCryptoAmount is also set.
* Also note this only works for a subset of fiat currencies: USD, CAD, GBP, EUR
* */
presetFiatAmount?: number;
/** ID used to link all user transactions created during the session. */
partnerUserId?: string;
};

export type OffRampAggregatorAppParams = {
quoteId?: string;
defaultAsset?: string;
defaultNetwork?: string;
defaultCashoutMethod?: string; // "CRYPTO_ACCOUNT" | "FIAT_WALLET" | "CARD" | "ACH_BANK_ACCOUNT" | "PAYPAL"
presetFiatAmount?: number;
fiatCurrency?: string;
};

export type OffRampAppParams =
| BaseOffRampAppParams
| (BaseOffRampAppParams & OffRampAggregatorAppParams);
2 changes: 1 addition & 1 deletion src/types/widget.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventMetadata } from './events';

export type WidgetType = 'buy' | 'checkout';
export type WidgetType = 'buy' | 'checkout' | 'sell';

export type IntegrationType = 'direct' | 'secure_standalone';

Expand Down
1 change: 1 addition & 0 deletions src/utils/CBPayInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type CBPayInstanceConstructorArguments = {
const widgetRoutes: Record<WidgetType, string> = {
buy: '/buy',
checkout: '/checkout',
sell: '/v3/sell',
};

export interface CBPayInstanceType {
Expand Down
53 changes: 53 additions & 0 deletions src/utils/CoinbasePixel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@ describe('CoinbasePixel', () => {
});
});

it('should handle opening offramp the new_tab experience in chrome extensions', () => {
window.chrome = {
// @ts-expect-error - test
tabs: {
create: jest.fn(),
},
};

const instance = new CoinbasePixel(defaultArgs);

instance.openExperience({
...defaultOpenOptions,
experienceLoggedIn: 'new_tab',
path: '/v3/sell',
});

expect(window.chrome.tabs.create).toHaveBeenCalledWith({
url: 'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test',
});
});

it('should handle opening the popup experience in browsers', () => {
const instance = new CoinbasePixel(defaultArgs);

Expand All @@ -119,6 +140,22 @@ describe('CoinbasePixel', () => {
);
});

it('should handle opening offramp in the popup experience in browsers', () => {
const instance = new CoinbasePixel(defaultArgs);

instance.openExperience({
...defaultOpenOptions,
experienceLoggedIn: 'popup',
path: '/v3/sell',
});

expect(window.open).toHaveBeenCalledWith(
'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test',
'Coinbase',
'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, height=730,width=460',
);
});

it('should handle opening the new_tab experience in browsers', () => {
const instance = new CoinbasePixel(defaultArgs);

Expand All @@ -131,6 +168,22 @@ describe('CoinbasePixel', () => {
);
});

it('should handle opening the offramp experience in new_tab in browsers', () => {
const instance = createUntypedPixel(defaultArgs);

instance.openExperience({
...defaultOpenOptions,
experienceLoggedIn: 'new_tab',
path: '/v3/sell',
});

expect(window.open).toHaveBeenCalledWith(
'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test',
'Coinbase',
undefined,
);
});

it('.destroy should remove embedded pixel', () => {
const instance = createUntypedPixel(defaultArgs);
expect(instance.unsubs).toHaveLength(0);
Expand Down
23 changes: 17 additions & 6 deletions src/utils/CoinbasePixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { JsonObject } from 'types/JsonTypes';
import { onBroadcastedPostMessage } from './postMessage';
import { EventMetadata } from 'types/events';
import { generateOnRampURL } from '../onramp/generateOnRampURL';
import { generateOffRampURL } from '../offramp/generateOffRampURL';

const PopupSizes: Record<'signin' | 'widget', { width: number; height: number }> = {
signin: {
Expand Down Expand Up @@ -73,12 +74,22 @@ export class CoinbasePixel {

const experience = experienceLoggedOut || experienceLoggedIn;

const url = generateOnRampURL({
appId: this.appId,
host: this.host,
theme: this.theme ?? undefined,
...this.appParams,
});
let url = '';
if (options.path === '/v3/sell') {
url = generateOffRampURL({
appId: this.appId,
host: this.host,
theme: this.theme ?? undefined,
...this.appParams,
});
} else {
url = generateOnRampURL({
appId: this.appId,
host: this.host,
theme: this.theme ?? undefined,
...this.appParams,
});
}

this.log('Opening experience', { experience });

Expand Down
Loading

0 comments on commit cb2f991

Please sign in to comment.