From fab155b4115d03b90e51fd4db7765ec642fe342d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 06:23:14 +0000 Subject: [PATCH] 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) --- modules/rcart/components/folk-payment-page.ts | 38 +++-- modules/rcart/mod.ts | 72 +++++++++ shared/moonpay.ts | 153 ++++++++++++++++++ 3 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 shared/moonpay.ts diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts index 6fba6ea..2ffc4ff 100644 --- a/modules/rcart/components/folk-payment-page.ts +++ b/modules/rcart/components/folk-payment-page.ts @@ -137,14 +137,15 @@ class FolkPaymentPage extends HTMLElement { // ── Card tab: Transak ── - private async startTransak() { + private async startCardPayment() { if (!this.cardEmail) return; this.cardLoading = true; this.render(); try { 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: this.cardEmail, ...(effectiveAmount ? { amount: effectiveAmount } : {}) }), @@ -152,16 +153,17 @@ class FolkPaymentPage extends HTMLElement { const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to create session'); - this.transakEnv = data.env || 'PRODUCTION'; this.transakUrl = data.widgetUrl; + this.transakEnv = data.env || 'sandbox'; + const provider = data.provider || 'transak'; - // Listen for Transak postMessage events - window.addEventListener('message', this.handleTransakMessage); + // Listen for postMessage events from both MoonPay and Transak + 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( data.widgetUrl, - 'transak-payment', + 'card-payment', 'width=450,height=700,scrollbars=yes,resizable=yes', ); // Poll for popup close (user cancelled) @@ -178,12 +180,18 @@ class FolkPaymentPage extends HTMLElement { this.render(); } - private handleTransakMessage = (e: MessageEvent) => { - if (!e.data?.event_id) return; - if (e.data.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data.event_id === 'TRANSAK_ORDER_COMPLETED') { + private handlePaymentMessage = (e: MessageEvent) => { + // Transak events + 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; 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 {

Transak payment opened in a new window.

Complete the payment in the popup window. This page will update automatically.

- +
`; } @@ -585,7 +593,7 @@ class FolkPaymentPage extends HTMLElement {
- ${this.error ? `
${this.esc(this.error)}
` : ''} @@ -677,8 +685,8 @@ class FolkPaymentPage extends HTMLElement { // Card tab const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement; emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; }); - this.shadow.querySelector('[data-action="start-transak"]')?.addEventListener('click', () => this.startTransak()); - this.shadow.querySelector('[data-action="reopen-transak"]')?.addEventListener('click', () => { + this.shadow.querySelector('[data-action="start-card-payment"]')?.addEventListener('click', () => this.startCardPayment()); + this.shadow.querySelector('[data-action="reopen-card-payment"]')?.addEventListener('click', () => { if (this.transakUrl) { this.transakPopup = window.open( this.transakUrl, diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index c0c48c6..38daf12 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -31,6 +31,7 @@ import { } from './schemas'; import { extractProductFromUrl } from './extract'; import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak'; +import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay'; import QRCode from 'qrcode'; import { createTransport, type Transporter } from "nodemailer"; import { @@ -1796,6 +1797,77 @@ routes.post("/api/payments/:id/transak-session", async (c) => { 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(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 = { 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 = { + 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 routes.post("/api/payments/:id/share-email", async (c) => { const space = c.req.param("space") || "demo"; diff --git a/shared/moonpay.ts b/shared/moonpay.ts new file mode 100644 index 0000000..fc53eae --- /dev/null +++ b/shared/moonpay.ts @@ -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 = { + 8453: 'base', + 84532: 'base', + 1: 'ethereum', + 137: 'polygon', + 42161: 'arbitrum', +}; + +/** Token symbol to MoonPay currency code */ +const CURRENCY_MAP: Record> = { + 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); +}