feat(rcart): add MoonPay integration with Transak fallback

Add MoonPay as the primary card payment provider for rCart. MoonPay
uses HMAC-SHA256 signed URLs (no session API needed, no IP whitelisting).
Falls back to Transak if MoonPay keys aren't configured.

- shared/moonpay.ts: URL builder with HMAC signing, currency mapping
- New /api/payments/:id/card-session endpoint picks provider automatically
- Frontend uses unified startCardPayment() with multi-provider message handling
- Set MOONPAY_API_KEY + MOONPAY_SECRET_KEY to activate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-03 06:23:14 +00:00
parent c1e8048089
commit fab155b411
3 changed files with 248 additions and 15 deletions

View File

@ -137,14 +137,15 @@ class FolkPaymentPage extends HTMLElement {
// ── Card tab: Transak ── // ── Card tab: Transak ──
private async startTransak() { private async startCardPayment() {
if (!this.cardEmail) return; if (!this.cardEmail) return;
this.cardLoading = true; this.cardLoading = true;
this.render(); this.render();
try { try {
const effectiveAmount = this.getEffectiveAmount(); const effectiveAmount = this.getEffectiveAmount();
const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/transak-session`, { // Use the unified card-session endpoint (picks MoonPay or Transak server-side)
const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/card-session`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.cardEmail, ...(effectiveAmount ? { amount: effectiveAmount } : {}) }), body: JSON.stringify({ email: this.cardEmail, ...(effectiveAmount ? { amount: effectiveAmount } : {}) }),
@ -152,16 +153,17 @@ class FolkPaymentPage extends HTMLElement {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create session'); if (!res.ok) throw new Error(data.error || 'Failed to create session');
this.transakEnv = data.env || 'PRODUCTION';
this.transakUrl = data.widgetUrl; this.transakUrl = data.widgetUrl;
this.transakEnv = data.env || 'sandbox';
const provider = data.provider || 'transak';
// Listen for Transak postMessage events // Listen for postMessage events from both MoonPay and Transak
window.addEventListener('message', this.handleTransakMessage); window.addEventListener('message', this.handlePaymentMessage);
// Transak blocks iframes (X-Frame-Options: sameorigin) — always use popup // Open in popup (both providers block iframes)
this.transakPopup = window.open( this.transakPopup = window.open(
data.widgetUrl, data.widgetUrl,
'transak-payment', 'card-payment',
'width=450,height=700,scrollbars=yes,resizable=yes', 'width=450,height=700,scrollbars=yes,resizable=yes',
); );
// Poll for popup close (user cancelled) // Poll for popup close (user cancelled)
@ -178,12 +180,18 @@ class FolkPaymentPage extends HTMLElement {
this.render(); this.render();
} }
private handleTransakMessage = (e: MessageEvent) => { private handlePaymentMessage = (e: MessageEvent) => {
if (!e.data?.event_id) return; // Transak events
if (e.data.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data.event_id === 'TRANSAK_ORDER_COMPLETED') { if (e.data?.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data?.event_id === 'TRANSAK_ORDER_COMPLETED') {
const orderId = e.data.data?.id || e.data.data?.orderId; const orderId = e.data.data?.id || e.data.data?.orderId;
this.updatePaymentStatus('paid', 'transak', null, orderId); this.updatePaymentStatus('paid', 'transak', null, orderId);
window.removeEventListener('message', this.handleTransakMessage); window.removeEventListener('message', this.handlePaymentMessage);
}
// MoonPay events
if (e.data?.type === 'moonpay_onTransactionCompleted' || e.data?.type === 'MOONPAY_TRANSACTION_COMPLETED') {
const txId = e.data.data?.id || e.data.transactionId;
this.updatePaymentStatus('paid', 'moonpay', null, txId);
window.removeEventListener('message', this.handlePaymentMessage);
} }
}; };
@ -574,7 +582,7 @@ class FolkPaymentPage extends HTMLElement {
<div class="transak-popup-status"> <div class="transak-popup-status">
<p class="tab-desc">Transak payment opened in a new window.</p> <p class="tab-desc">Transak payment opened in a new window.</p>
<p class="tab-desc" style="font-size:0.8125rem; color:var(--rs-text-muted)">Complete the payment in the popup window. This page will update automatically.</p> <p class="tab-desc" style="font-size:0.8125rem; color:var(--rs-text-muted)">Complete the payment in the popup window. This page will update automatically.</p>
<button class="btn" data-action="reopen-transak" style="margin-top:0.5rem">Re-open Payment Window</button> <button class="btn" data-action="reopen-card-payment" style="margin-top:0.5rem">Re-open Payment Window</button>
</div> </div>
</div>`; </div>`;
} }
@ -585,7 +593,7 @@ class FolkPaymentPage extends HTMLElement {
<div class="form-row"> <div class="form-row">
<input type="email" placeholder="Your email address" class="input" data-field="card-email" value="${this.esc(this.cardEmail)}" /> <input type="email" placeholder="Your email address" class="input" data-field="card-email" value="${this.esc(this.cardEmail)}" />
</div> </div>
<button class="btn btn-primary" data-action="start-transak" ${this.cardLoading ? 'disabled' : ''}> <button class="btn btn-primary" data-action="start-card-payment" ${this.cardLoading ? 'disabled' : ''}>
${this.cardLoading ? 'Loading...' : 'Pay with Card'} ${this.cardLoading ? 'Loading...' : 'Pay with Card'}
</button> </button>
${this.error ? `<div class="field-error">${this.esc(this.error)}</div>` : ''} ${this.error ? `<div class="field-error">${this.esc(this.error)}</div>` : ''}
@ -677,8 +685,8 @@ class FolkPaymentPage extends HTMLElement {
// Card tab // Card tab
const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement; const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement;
emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; }); emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; });
this.shadow.querySelector('[data-action="start-transak"]')?.addEventListener('click', () => this.startTransak()); this.shadow.querySelector('[data-action="start-card-payment"]')?.addEventListener('click', () => this.startCardPayment());
this.shadow.querySelector('[data-action="reopen-transak"]')?.addEventListener('click', () => { this.shadow.querySelector('[data-action="reopen-card-payment"]')?.addEventListener('click', () => {
if (this.transakUrl) { if (this.transakUrl) {
this.transakPopup = window.open( this.transakPopup = window.open(
this.transakUrl, this.transakUrl,

View File

@ -31,6 +31,7 @@ import {
} from './schemas'; } from './schemas';
import { extractProductFromUrl } from './extract'; import { extractProductFromUrl } from './extract';
import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak'; import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak';
import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { createTransport, type Transporter } from "nodemailer"; import { createTransport, type Transporter } from "nodemailer";
import { import {
@ -1796,6 +1797,77 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
return c.json({ widgetUrl, env: transakEnv }); return c.json({ widgetUrl, env: transakEnv });
}); });
// POST /api/payments/:id/card-session — Get on-ramp widget URL (MoonPay preferred, Transak fallback)
routes.post("/api/payments/:id/card-session", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const p = doc.payment;
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);
const { email, amount: overrideAmount } = await c.req.json();
if (!email) return c.json({ error: "Required: email" }, 400);
const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;
// Try MoonPay first (simpler, no session API needed)
const moonPayKey = getMoonPayApiKey();
if (moonPayKey) {
try {
const widgetUrl = createMoonPayPaymentUrl({
walletAddress: p.recipientAddress,
token: p.token,
chainId: p.chainId,
amount: effectiveAmount,
fiatAmount: p.fiatAmount || undefined,
fiatCurrency: p.fiatCurrency || 'USD',
email,
paymentId,
});
return c.json({ widgetUrl, provider: 'moonpay', env: getMoonPayEnv() });
} catch (err) {
console.error('[rcart] MoonPay URL generation failed:', err);
}
}
// Fall back to Transak
const transakApiKey = getTransakApiKey();
if (!transakApiKey) return c.json({ error: "No payment provider configured" }, 503);
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).hostname;
const widgetParams: Record<string, string> = {
apiKey: transakApiKey,
referrerDomain: extractRootDomain(host),
cryptoCurrencyCode: p.token,
network: networkMap[p.chainId] || 'base',
defaultCryptoCurrency: p.token,
walletAddress: p.recipientAddress,
disableWalletAddressForm: 'true',
defaultCryptoAmount: effectiveAmount,
partnerOrderId: `pay-${paymentId}`,
email,
themeColor: '6366f1',
hideMenu: 'true',
};
if (p.fiatAmount) {
widgetParams.fiatAmount = p.fiatAmount;
widgetParams.defaultFiatAmount = p.fiatAmount;
}
if (p.fiatCurrency) {
widgetParams.fiatCurrency = p.fiatCurrency;
widgetParams.defaultFiatCurrency = p.fiatCurrency;
}
const widgetUrl = await createSecureWidgetUrl(widgetParams);
return c.json({ widgetUrl, provider: 'transak', env: getTransakEnv() });
});
// POST /api/payments/:id/share-email — Email payment link to recipients // POST /api/payments/:id/share-email — Email payment link to recipients
routes.post("/api/payments/:id/share-email", async (c) => { routes.post("/api/payments/:id/share-email", async (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";

153
shared/moonpay.ts Normal file
View File

@ -0,0 +1,153 @@
/**
* MoonPay API utilities fiat-to-crypto on-ramp.
*
* MoonPay widget URLs are built with query params and signed server-side
* with HMAC-SHA256 using the API secret. No session API needed.
*
* Environment: MOONPAY_ENV controls sandbox vs production.
* Docs: https://dev.moonpay.com/
*/
import { createHmac } from 'crypto';
export type MoonPayEnv = 'sandbox' | 'production';
const WIDGET_URLS = {
sandbox: 'https://buy-sandbox.moonpay.com',
production: 'https://buy.moonpay.com',
} as const;
/** Get current MoonPay environment */
export function getMoonPayEnv(): MoonPayEnv {
const env = process.env.MOONPAY_ENV?.toLowerCase();
return env === 'production' ? 'production' : 'sandbox';
}
/** Get MoonPay publishable API key */
export function getMoonPayApiKey(): string {
const env = getMoonPayEnv();
return (env === 'production'
? process.env.MOONPAY_API_KEY_PRODUCTION
: process.env.MOONPAY_API_KEY_SANDBOX
) || process.env.MOONPAY_API_KEY || '';
}
/** Get MoonPay secret key (for URL signing) */
export function getMoonPaySecretKey(): string {
const env = getMoonPayEnv();
return (env === 'production'
? process.env.MOONPAY_SECRET_KEY_PRODUCTION
: process.env.MOONPAY_SECRET_KEY_SANDBOX
) || process.env.MOONPAY_SECRET_KEY || '';
}
/** Chain ID to MoonPay network name */
const NETWORK_MAP: Record<number, string> = {
8453: 'base',
84532: 'base',
1: 'ethereum',
137: 'polygon',
42161: 'arbitrum',
};
/** Token symbol to MoonPay currency code */
const CURRENCY_MAP: Record<string, Record<string, string>> = {
USDC: {
base: 'usdc_base',
ethereum: 'usdc',
polygon: 'usdc_polygon',
arbitrum: 'usdc_arbitrum',
},
ETH: {
base: 'eth_base',
ethereum: 'eth',
arbitrum: 'eth_arbitrum',
},
};
export interface MoonPayWidgetParams {
walletAddress: string;
email?: string;
currencyCode?: string; // e.g. 'usdc_base'
baseCurrencyCode?: string; // e.g. 'usd'
baseCurrencyAmount?: number; // fiat amount
quoteCurrencyAmount?: number; // crypto amount
externalTransactionId?: string;
colorCode?: string; // hex without # (e.g. '6366f1')
redirectURL?: string;
showWalletAddressForm?: boolean;
}
/**
* Build a signed MoonPay widget URL.
* The URL is signed with HMAC-SHA256 using the secret key.
*/
export function createMoonPayWidgetUrl(params: MoonPayWidgetParams): string {
const env = getMoonPayEnv();
const apiKey = getMoonPayApiKey();
const secretKey = getMoonPaySecretKey();
if (!apiKey) throw new Error('MoonPay API key not configured');
if (!secretKey) throw new Error('MoonPay secret key not configured');
const base = WIDGET_URLS[env];
const url = new URL(base);
url.searchParams.set('apiKey', apiKey);
url.searchParams.set('walletAddress', params.walletAddress);
if (params.currencyCode) url.searchParams.set('currencyCode', params.currencyCode);
if (params.baseCurrencyCode) url.searchParams.set('baseCurrencyCode', params.baseCurrencyCode);
if (params.baseCurrencyAmount) url.searchParams.set('baseCurrencyAmount', String(params.baseCurrencyAmount));
if (params.quoteCurrencyAmount) url.searchParams.set('quoteCurrencyAmount', String(params.quoteCurrencyAmount));
if (params.email) url.searchParams.set('email', params.email);
if (params.externalTransactionId) url.searchParams.set('externalTransactionId', params.externalTransactionId);
if (params.colorCode) url.searchParams.set('colorCode', params.colorCode);
if (params.redirectURL) url.searchParams.set('redirectURL', params.redirectURL);
if (params.showWalletAddressForm === false) url.searchParams.set('showWalletAddressForm', 'false');
// Sign the URL: HMAC-SHA256 of the query string (including the leading '?')
const signature = createHmac('sha256', secretKey)
.update(url.search)
.digest('base64url');
url.searchParams.set('signature', signature);
return url.toString();
}
/**
* Build a MoonPay widget URL for a payment request.
* Convenience wrapper that maps token/chainId to MoonPay currency codes.
*/
export function createMoonPayPaymentUrl(opts: {
walletAddress: string;
token: string;
chainId: number;
amount?: string;
fiatAmount?: string;
fiatCurrency?: string;
email?: string;
paymentId?: string;
}): string {
const network = NETWORK_MAP[opts.chainId] || 'base';
const currencyCode = CURRENCY_MAP[opts.token]?.[network] || 'usdc_base';
const params: MoonPayWidgetParams = {
walletAddress: opts.walletAddress,
currencyCode,
email: opts.email,
externalTransactionId: opts.paymentId ? `pay-${opts.paymentId}` : undefined,
colorCode: '6366f1',
showWalletAddressForm: false,
};
// Prefer fiat amount if available, otherwise use crypto amount
if (opts.fiatAmount && parseFloat(opts.fiatAmount) > 0) {
params.baseCurrencyAmount = parseFloat(opts.fiatAmount);
params.baseCurrencyCode = (opts.fiatCurrency || 'USD').toLowerCase();
} else if (opts.amount && parseFloat(opts.amount) > 0) {
params.quoteCurrencyAmount = parseFloat(opts.amount);
}
return createMoonPayWidgetUrl(params);
}