Skip to content

Commit cb2f991

Browse files
authored
Add Offramp support (#302)
* 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
1 parent a206100 commit cb2f991

File tree

10 files changed

+467
-89
lines changed

10 files changed

+467
-89
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { generateOffRampURL } from './generateOffRampURL';
2+
3+
describe('generateOffRampURL', () => {
4+
it('generates URL with expected default parameters', () => {
5+
const url = new URL(
6+
generateOffRampURL({
7+
appId: 'test',
8+
}),
9+
);
10+
11+
expect(url.origin).toEqual('https://pay.coinbase.com');
12+
expect(url.pathname).toEqual('/v3/sell/input');
13+
expect(url.searchParams.get('appId')).toEqual('test');
14+
});
15+
16+
it('should support redirectUrl', () => {
17+
const url = new URL(
18+
generateOffRampURL({
19+
appId: 'test',
20+
redirectUrl: 'https://example.com',
21+
}),
22+
);
23+
24+
expect(url.searchParams.get('redirectUrl')).toEqual('https://example.com');
25+
});
26+
27+
it('generates URL with multiple addresses', () => {
28+
const addresses = {
29+
'0x1': ['base', 'ethereum'],
30+
'123abc': ['solana'],
31+
};
32+
33+
const url = new URL(
34+
generateOffRampURL({
35+
appId: 'test',
36+
addresses,
37+
redirectUrl: 'https://example.com',
38+
}),
39+
);
40+
41+
expect(url.searchParams.get('addresses')).toEqual(
42+
'{"0x1":["base","ethereum"],"123abc":["solana"]}',
43+
);
44+
});
45+
46+
it('generates URL with multiple addresses and assets', () => {
47+
const url = new URL(
48+
generateOffRampURL({
49+
appId: 'test',
50+
addresses: {
51+
'0x5ome4ddre55': ['ethereum', 'avalanche-c-chain'],
52+
'90123jd09ef09df': ['solana'],
53+
},
54+
assets: ['USDC', 'SOL'],
55+
}),
56+
);
57+
58+
expect(url.searchParams.get('addresses')).toEqual(
59+
`{\"0x5ome4ddre55\":[\"ethereum\",\"avalanche-c-chain\"],\"90123jd09ef09df\":[\"solana\"]}`,
60+
);
61+
expect(url.searchParams.get('assets')).toEqual('["USDC","SOL"]');
62+
});
63+
64+
it('should support dynamic host', () => {
65+
const url = new URL(
66+
generateOffRampURL({
67+
host: 'http://localhost:3000',
68+
appId: 'test',
69+
}),
70+
);
71+
72+
expect(url.origin).toEqual('http://localhost:3000');
73+
expect(url.pathname).toEqual('/v3/sell/input');
74+
expect(url.searchParams.get('appId')).toEqual('test');
75+
});
76+
77+
it('should support preset amounts', () => {
78+
const url = new URL(
79+
generateOffRampURL({
80+
appId: 'test',
81+
presetCryptoAmount: 0.1,
82+
presetFiatAmount: 20,
83+
}),
84+
);
85+
86+
expect(url.searchParams.get('presetFiatAmount')).toEqual('20');
87+
expect(url.searchParams.get('presetCryptoAmount')).toEqual('0.1');
88+
});
89+
90+
it('should support defaultNetwork', () => {
91+
const url = new URL(
92+
generateOffRampURL({
93+
appId: 'test',
94+
defaultNetwork: 'ethereum',
95+
}),
96+
);
97+
expect(url.searchParams.get('defaultNetwork')).toEqual('ethereum');
98+
});
99+
100+
it('should support sessionToken', () => {
101+
const url = new URL(
102+
generateOffRampURL({
103+
sessionToken: 'test',
104+
}),
105+
);
106+
expect(url.origin).toEqual('https://pay.coinbase.com');
107+
expect(url.pathname).toEqual('/v3/sell/input');
108+
expect(url.searchParams.get('sessionToken')).toEqual('test');
109+
});
110+
});

src/offramp/generateOffRampURL.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { OffRampAppParams } from 'types/offramp';
2+
import { DEFAULT_HOST } from '../config';
3+
import type { Theme } from '../types/widget';
4+
5+
export type GenerateOffRampURLOptions = {
6+
/** This & addresses or sessionToken are required. */
7+
appId?: string;
8+
host?: string;
9+
theme?: Theme;
10+
/** This or appId & addresses are required. */
11+
sessionToken?: string;
12+
} & OffRampAppParams;
13+
14+
export const generateOffRampURL = ({
15+
host = DEFAULT_HOST,
16+
...props
17+
}: GenerateOffRampURLOptions): string => {
18+
const url = new URL(host);
19+
url.pathname = '/v3/sell/input';
20+
21+
(Object.keys(props) as (keyof typeof props)[]).forEach((key) => {
22+
const value = props[key];
23+
if (value !== undefined) {
24+
if (['string', 'number', 'boolean'].includes(typeof value)) {
25+
url.searchParams.append(key, value.toString());
26+
} else {
27+
url.searchParams.append(key, JSON.stringify(value));
28+
}
29+
}
30+
});
31+
32+
url.searchParams.sort();
33+
34+
return url.toString();
35+
};

src/offramp/initOffRamp.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { initOffRamp } from './initOffRamp';
2+
import { CBPayInstance } from '../utils/CBPayInstance';
3+
4+
jest.mock('../utils/CBPayInstance');
5+
6+
describe('initOffRamp', () => {
7+
it('should return CBPayInstance', async () => {
8+
let instance: unknown;
9+
initOffRamp(
10+
{
11+
experienceLoggedIn: 'popup',
12+
experienceLoggedOut: 'popup',
13+
appId: 'abc123',
14+
widgetParameters: { addresses: { '0x1': ['base'] }, redirectUrl: 'https://example.com' },
15+
},
16+
(_, newInstance) => {
17+
instance = newInstance;
18+
},
19+
);
20+
21+
expect(CBPayInstance).toHaveBeenCalledTimes(1);
22+
23+
expect(instance instanceof CBPayInstance).toBe(true);
24+
});
25+
});

src/offramp/initOffRamp.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { CBPayExperienceOptions } from '../types/widget';
2+
import { CBPayInstance, CBPayInstanceType } from '../utils/CBPayInstance';
3+
import { OffRampAppParams } from '../types/offramp';
4+
5+
export type InitOffRampParams = CBPayExperienceOptions<OffRampAppParams>;
6+
7+
export type InitOffRampCallback = {
8+
(error: Error, instance: null): void;
9+
(error: null, instance: CBPayInstanceType): void;
10+
};
11+
12+
export const initOffRamp = (
13+
{
14+
experienceLoggedIn = 'new_tab', // default experience type
15+
widgetParameters,
16+
...options
17+
}: InitOffRampParams,
18+
callback: InitOffRampCallback,
19+
): void => {
20+
const instance = new CBPayInstance({
21+
...options,
22+
widget: 'sell',
23+
experienceLoggedIn,
24+
appParams: widgetParameters,
25+
});
26+
callback(null, instance);
27+
};

src/types/offramp.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export type BaseOffRampAppParams = {
2+
/**
3+
*
4+
* Each entry in the record represents a wallet address and the networks it is valid for. There should only be a
5+
* single address for each network your app supports. Users will be able to cash out any owned assets supported by any of
6+
* the networks you specify. See the assets parameter if you want to restrict the available assets.
7+
*
8+
* Example:
9+
*
10+
* Show all assets users have on the base network, only on the base network:
11+
*
12+
* `{ "0x1": ["base"] }`
13+
*
14+
*/
15+
addresses?: Record<string, string[]>;
16+
/** A URL that the user will be redirected to after to sign their transaction after the transaction has been committed. */
17+
redirectUrl?: string;
18+
/**
19+
* This optional parameter will restrict the assets available for the user to cash out. It acts as a filter on the
20+
* networks specified in the {addresses} param.
21+
*
22+
* Example:
23+
*
24+
* Support only USDC on either the base network or the ethereum network:
25+
*
26+
* `addresses: { "0x1": ["base", "ethereum"] }, assets: ["USDC"]`
27+
*
28+
*/
29+
assets?: string[];
30+
/** The default network that should be selected when multiple networks are present. */
31+
defaultNetwork?: string;
32+
/** The preset input amount as a crypto value. i.e. 0.1 ETH. */
33+
presetCryptoAmount?: number;
34+
/**
35+
* The preset input amount as a fiat value. i.e. 15 USD.
36+
* Ignored if presetCryptoAmount is also set.
37+
* Also note this only works for a subset of fiat currencies: USD, CAD, GBP, EUR
38+
* */
39+
presetFiatAmount?: number;
40+
/** ID used to link all user transactions created during the session. */
41+
partnerUserId?: string;
42+
};
43+
44+
export type OffRampAggregatorAppParams = {
45+
quoteId?: string;
46+
defaultAsset?: string;
47+
defaultNetwork?: string;
48+
defaultCashoutMethod?: string; // "CRYPTO_ACCOUNT" | "FIAT_WALLET" | "CARD" | "ACH_BANK_ACCOUNT" | "PAYPAL"
49+
presetFiatAmount?: number;
50+
fiatCurrency?: string;
51+
};
52+
53+
export type OffRampAppParams =
54+
| BaseOffRampAppParams
55+
| (BaseOffRampAppParams & OffRampAggregatorAppParams);

src/types/widget.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EventMetadata } from './events';
22

3-
export type WidgetType = 'buy' | 'checkout';
3+
export type WidgetType = 'buy' | 'checkout' | 'sell';
44

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

src/utils/CBPayInstance.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type CBPayInstanceConstructorArguments = {
1717
const widgetRoutes: Record<WidgetType, string> = {
1818
buy: '/buy',
1919
checkout: '/checkout',
20+
sell: '/v3/sell',
2021
};
2122

2223
export interface CBPayInstanceType {

src/utils/CoinbasePixel.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,27 @@ describe('CoinbasePixel', () => {
107107
});
108108
});
109109

110+
it('should handle opening offramp the new_tab experience in chrome extensions', () => {
111+
window.chrome = {
112+
// @ts-expect-error - test
113+
tabs: {
114+
create: jest.fn(),
115+
},
116+
};
117+
118+
const instance = new CoinbasePixel(defaultArgs);
119+
120+
instance.openExperience({
121+
...defaultOpenOptions,
122+
experienceLoggedIn: 'new_tab',
123+
path: '/v3/sell',
124+
});
125+
126+
expect(window.chrome.tabs.create).toHaveBeenCalledWith({
127+
url: 'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test',
128+
});
129+
});
130+
110131
it('should handle opening the popup experience in browsers', () => {
111132
const instance = new CoinbasePixel(defaultArgs);
112133

@@ -119,6 +140,22 @@ describe('CoinbasePixel', () => {
119140
);
120141
});
121142

143+
it('should handle opening offramp in the popup experience in browsers', () => {
144+
const instance = new CoinbasePixel(defaultArgs);
145+
146+
instance.openExperience({
147+
...defaultOpenOptions,
148+
experienceLoggedIn: 'popup',
149+
path: '/v3/sell',
150+
});
151+
152+
expect(window.open).toHaveBeenCalledWith(
153+
'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test',
154+
'Coinbase',
155+
'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, height=730,width=460',
156+
);
157+
});
158+
122159
it('should handle opening the new_tab experience in browsers', () => {
123160
const instance = new CoinbasePixel(defaultArgs);
124161

@@ -131,6 +168,22 @@ describe('CoinbasePixel', () => {
131168
);
132169
});
133170

171+
it('should handle opening the offramp experience in new_tab in browsers', () => {
172+
const instance = createUntypedPixel(defaultArgs);
173+
174+
instance.openExperience({
175+
...defaultOpenOptions,
176+
experienceLoggedIn: 'new_tab',
177+
path: '/v3/sell',
178+
});
179+
180+
expect(window.open).toHaveBeenCalledWith(
181+
'https://pay.coinbase.com/v3/sell/input?addresses=%7B%220x0%22%3A%5B%22ethereum%22%5D%7D&appId=test',
182+
'Coinbase',
183+
undefined,
184+
);
185+
});
186+
134187
it('.destroy should remove embedded pixel', () => {
135188
const instance = createUntypedPixel(defaultArgs);
136189
expect(instance.unsubs).toHaveLength(0);

src/utils/CoinbasePixel.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { JsonObject } from 'types/JsonTypes';
55
import { onBroadcastedPostMessage } from './postMessage';
66
import { EventMetadata } from 'types/events';
77
import { generateOnRampURL } from '../onramp/generateOnRampURL';
8+
import { generateOffRampURL } from '../offramp/generateOffRampURL';
89

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

7475
const experience = experienceLoggedOut || experienceLoggedIn;
7576

76-
const url = generateOnRampURL({
77-
appId: this.appId,
78-
host: this.host,
79-
theme: this.theme ?? undefined,
80-
...this.appParams,
81-
});
77+
let url = '';
78+
if (options.path === '/v3/sell') {
79+
url = generateOffRampURL({
80+
appId: this.appId,
81+
host: this.host,
82+
theme: this.theme ?? undefined,
83+
...this.appParams,
84+
});
85+
} else {
86+
url = generateOnRampURL({
87+
appId: this.appId,
88+
host: this.host,
89+
theme: this.theme ?? undefined,
90+
...this.appParams,
91+
});
92+
}
8293

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

0 commit comments

Comments
 (0)