Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Offramp support #302

Merged
merged 12 commits into from
Oct 18, 2024
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
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
Loading