rspace-online/modules/rflows/lib/coinbase-onramp.ts

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 };
}
}