157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
/**
|
|
* Coinbase Onramp Provider
|
|
*
|
|
* Generates session tokens for the Coinbase-hosted Onramp widget.
|
|
* Uses Ed25519 JWT auth against the CDP API to get a session token,
|
|
* then constructs a one-click-buy URL for the iframe.
|
|
*
|
|
* Docs: https://docs.cdp.coinbase.com/onramp/docs/welcome
|
|
*/
|
|
|
|
import { SignJWT } from 'jose';
|
|
import { randomBytes, createPrivateKey } from 'crypto';
|
|
|
|
const CDP_API_HOST = 'api.developer.coinbase.com';
|
|
const TOKEN_PATH = '/onramp/v1/token';
|
|
const ONRAMP_BASE_URL = 'https://pay.coinbase.com/buy';
|
|
|
|
export interface CoinbaseOnrampConfig {
|
|
apiKeyId: string;
|
|
apiKeySecret: string; // Ed25519 private key (PEM or base64)
|
|
projectId: string;
|
|
environment?: 'sandbox' | 'production';
|
|
}
|
|
|
|
export interface CoinbaseSessionOpts {
|
|
walletAddress: string;
|
|
fiatAmount: number;
|
|
fiatCurrency: string;
|
|
network?: string;
|
|
asset?: string;
|
|
partnerUserRef?: string;
|
|
redirectUrl?: string;
|
|
}
|
|
|
|
export interface CoinbaseSessionResult {
|
|
sessionToken: string;
|
|
onrampUrl: string;
|
|
}
|
|
|
|
export class CoinbaseOnrampProvider {
|
|
private config: CoinbaseOnrampConfig;
|
|
|
|
constructor(config: CoinbaseOnrampConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Generate a CDP JWT for authenticating against the onramp token endpoint.
|
|
* Uses EdDSA (Ed25519) signing per CDP docs.
|
|
*/
|
|
private async generateJwt(): Promise<string> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const nonce = randomBytes(16).toString('hex');
|
|
|
|
const keySecret = this.config.apiKeySecret.replace(/\\n/g, '\n').trim();
|
|
|
|
let privateKey: import('crypto').KeyObject;
|
|
if (keySecret.includes('-----BEGIN')) {
|
|
privateKey = createPrivateKey({ key: keySecret, format: 'pem' });
|
|
} else {
|
|
// Raw base64 key from CDP portal (64 bytes: 32-byte seed + 32-byte pubkey)
|
|
const rawBytes = Buffer.from(keySecret, 'base64');
|
|
const seed = rawBytes.subarray(0, 32);
|
|
// Wrap seed in PKCS8 DER envelope for Ed25519
|
|
const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
const pkcs8Der = Buffer.concat([pkcs8Header, seed]);
|
|
privateKey = createPrivateKey({ key: pkcs8Der, format: 'der', type: 'pkcs8' });
|
|
}
|
|
|
|
const jwt = await new SignJWT({
|
|
sub: this.config.apiKeyId,
|
|
iss: 'cdp',
|
|
aud: ['cdp_service'],
|
|
uri: `POST ${CDP_API_HOST}${TOKEN_PATH}`,
|
|
})
|
|
.setProtectedHeader({
|
|
alg: 'EdDSA',
|
|
typ: 'JWT',
|
|
kid: this.config.apiKeyId,
|
|
nonce,
|
|
})
|
|
.setIssuedAt(now)
|
|
.setNotBefore(now)
|
|
.setExpirationTime(now + 120)
|
|
.sign(privateKey);
|
|
|
|
return jwt;
|
|
}
|
|
|
|
/**
|
|
* Request a session token from the CDP Onramp API.
|
|
* Returns a short-lived token used to construct the widget URL.
|
|
*/
|
|
async createSession(opts: CoinbaseSessionOpts): Promise<CoinbaseSessionResult> {
|
|
const network = opts.network ?? 'base';
|
|
const asset = opts.asset ?? 'USDC';
|
|
|
|
// Build partnerUserRef — prefix with "sandbox-" for test mode
|
|
let partnerUserRef = opts.partnerUserRef ?? '';
|
|
if (this.config.environment === 'sandbox' && !partnerUserRef.startsWith('sandbox-')) {
|
|
partnerUserRef = `sandbox-${partnerUserRef}`;
|
|
}
|
|
|
|
const jwt = await this.generateJwt();
|
|
|
|
const body = {
|
|
addresses: [{
|
|
address: opts.walletAddress,
|
|
blockchains: [network],
|
|
}],
|
|
assets: [asset],
|
|
};
|
|
|
|
console.log(`[coinbase-onramp] Creating session: wallet=${opts.walletAddress} network=${network} asset=${asset} fiat=${opts.fiatAmount}`);
|
|
|
|
const res = await fetch(`https://${CDP_API_HOST}${TOKEN_PATH}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${jwt}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errBody = await res.text();
|
|
console.error(`[coinbase-onramp] CDP session token request failed: ${res.status} ${errBody}`);
|
|
throw new Error(`Coinbase Onramp session failed (${res.status}): ${errBody}`);
|
|
}
|
|
|
|
const data = await res.json() as { token: string; channel_id?: string };
|
|
const sessionToken = data.token;
|
|
|
|
// Construct the one-click-buy URL
|
|
const url = new URL(ONRAMP_BASE_URL);
|
|
url.searchParams.set('sessionToken', sessionToken);
|
|
url.searchParams.set('appId', this.config.projectId);
|
|
url.searchParams.set('defaultAsset', asset);
|
|
url.searchParams.set('defaultNetwork', network);
|
|
url.searchParams.set('defaultPaymentMethod', 'CARD');
|
|
url.searchParams.set('fiatCurrency', opts.fiatCurrency.toUpperCase());
|
|
url.searchParams.set('presetFiatAmount', opts.fiatAmount.toString());
|
|
|
|
if (opts.redirectUrl) {
|
|
url.searchParams.set('redirectUrl', opts.redirectUrl);
|
|
}
|
|
if (partnerUserRef) {
|
|
url.searchParams.set('partnerUserId', partnerUserRef);
|
|
}
|
|
|
|
const onrampUrl = url.toString();
|
|
console.log(`[coinbase-onramp] URL generated: ${onrampUrl.slice(0, 80)}...`);
|
|
|
|
return { sessionToken, onrampUrl };
|
|
}
|
|
}
|