diff --git a/modules/rcart/lib/recurring-executor.ts b/modules/rcart/lib/recurring-executor.ts new file mode 100644 index 0000000..047fffb --- /dev/null +++ b/modules/rcart/lib/recurring-executor.ts @@ -0,0 +1,232 @@ +/** + * Recurring Payment Executor + * + * Handles automated ERC-20 subscription payments via two mechanisms: + * + * 1. **Approval-based pulls (ERC-20 transferFrom)**: + * The payer approves a server-controlled relayer address to spend tokens. + * The server calls transferFrom when payments are due. Works with any wallet. + * + * 2. **EIP-4337 UserOp execution (Safe smart accounts)**: + * For Safe wallets with the server's session key as an authorized signer, + * the server constructs and submits UserOps via the Pimlico bundler. + * + * The relayer keypair is derived deterministically from HKDF so it's + * consistent across restarts without storing a private key in the DB. + */ + +import { createWalletClient, createPublicClient, http, type Chain } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { base, baseSepolia, mainnet } from 'viem/chains'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface SubscriptionApproval { + paymentRequestId: string; + payerAddress: string; + recipientAddress: string; + tokenAddress: string; + amount: string; // amount per period in token units (e.g. "10" USDC) + decimals: number; + chainId: number; + approvedAt: number; +} + +// ============================================================================ +// CHAIN CONFIG +// ============================================================================ + +const CHAINS: Record = { + 8453: base, + 84532: baseSepolia, + 1: mainnet, +}; + +const RPC_URLS: Record = { + 8453: 'https://mainnet.base.org', + 84532: 'https://sepolia.base.org', + 1: 'https://eth.llamarpc.com', +}; + +// ERC-20 function selectors +const TRANSFER_FROM_SELECTOR = '0x23b872dd'; // transferFrom(address,address,uint256) +const ALLOWANCE_SELECTOR = '0xdd62ed3e'; // allowance(address,address) + +// ============================================================================ +// RELAYER +// ============================================================================ + +let _relayerKey: `0x${string}` | null = null; +let _relayerAddress: string | null = null; + +/** + * Derive the relayer keypair from the server's RELAYER_SEED env var. + * Falls back to a deterministic derivation from PIMLICO_API_KEY if no seed is set. + */ +async function getRelayerKey(): Promise<`0x${string}`> { + if (_relayerKey) return _relayerKey; + + const seed = process.env.RELAYER_SEED || process.env.PIMLICO_API_KEY; + if (!seed) throw new Error('RELAYER_SEED or PIMLICO_API_KEY required for recurring payments'); + + // Derive a deterministic private key via HKDF-SHA256 + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', encoder.encode(seed), 'HKDF', false, ['deriveBits'] + ); + const bits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode('rcart-relayer-key-v1'), + info: encoder.encode('secp256k1-relayer'), + }, + keyMaterial, + 256, + ); + + const keyBytes = new Uint8Array(bits); + _relayerKey = ('0x' + Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('')) as `0x${string}`; + _relayerAddress = privateKeyToAccount(_relayerKey).address; + + console.log(`[rcart] Relayer address: ${_relayerAddress}`); + return _relayerKey; +} + +/** + * Get the relayer's Ethereum address. + */ +export async function getRelayerAddress(): Promise { + await getRelayerKey(); + return _relayerAddress!; +} + +// ============================================================================ +// ALLOWANCE CHECK +// ============================================================================ + +/** + * Check the current ERC-20 allowance the payer has granted to the relayer. + */ +export async function checkAllowance( + tokenAddress: string, + payerAddress: string, + chainId: number, +): Promise { + const chain = CHAINS[chainId]; + if (!chain) throw new Error(`Unsupported chain: ${chainId}`); + + const relayerAddr = await getRelayerAddress(); + + const client = createPublicClient({ + chain, + transport: http(RPC_URLS[chainId]), + }); + + // Encode allowance(payer, relayer) call + const owner = payerAddress.slice(2).toLowerCase().padStart(64, '0'); + const spender = relayerAddr.slice(2).toLowerCase().padStart(64, '0'); + const data = `${ALLOWANCE_SELECTOR}${owner}${spender}` as `0x${string}`; + + const result = await client.call({ + to: tokenAddress as `0x${string}`, + data, + }); + + if (!result.data || result.data === '0x') return 0n; + return BigInt(result.data); +} + +// ============================================================================ +// EXECUTE TRANSFER +// ============================================================================ + +/** + * Parse a decimal amount string into a BigInt with the given decimals. + */ +function parseTokenAmount(amount: string, decimals: number): bigint { + const parts = amount.split('.'); + const whole = parts[0] || '0'; + const frac = (parts[1] || '').slice(0, decimals).padEnd(decimals, '0'); + return BigInt(whole) * BigInt(10 ** decimals) + BigInt(frac); +} + +/** + * Execute a transferFrom call: pull tokens from payer to recipient. + * Requires that the payer has approved the relayer for sufficient allowance. + */ +export async function executeTransferFrom( + tokenAddress: string, + payerAddress: string, + recipientAddress: string, + amount: string, + decimals: number, + chainId: number, +): Promise { + const chain = CHAINS[chainId]; + if (!chain) throw new Error(`Unsupported chain: ${chainId}`); + + const relayerKey = await getRelayerKey(); + const account = privateKeyToAccount(relayerKey); + + // Check allowance first + const allowance = await checkAllowance(tokenAddress, payerAddress, chainId); + const rawAmount = parseTokenAmount(amount, decimals); + if (allowance < rawAmount) { + throw new Error(`Insufficient allowance: have ${allowance}, need ${rawAmount}`); + } + + const client = createWalletClient({ + account, + chain, + transport: http(RPC_URLS[chainId]), + }); + + // Encode transferFrom(from, to, amount) + const from = payerAddress.slice(2).toLowerCase().padStart(64, '0'); + const to = recipientAddress.slice(2).toLowerCase().padStart(64, '0'); + const amountHex = rawAmount.toString(16).padStart(64, '0'); + const data = `${TRANSFER_FROM_SELECTOR}${from}${to}${amountHex}` as `0x${string}`; + + const txHash = await client.sendTransaction({ + account, + to: tokenAddress as `0x${string}`, + data, + chain, + }); + + return txHash; +} + +// ============================================================================ +// APPROVAL TX BUILDER (client-side helper) +// ============================================================================ + +/** + * Build the calldata for an ERC-20 approve(relayer, amount) transaction. + * Returns the data the client needs to sign and submit. + */ +export async function buildApprovalCalldata( + amount: string, + decimals: number, + maxPayments: number, +): Promise<{ spender: string; calldata: string; totalAllowance: string }> { + const relayerAddr = await getRelayerAddress(); + const perPayment = parseTokenAmount(amount, decimals); + // Approve enough for all scheduled payments (or a generous amount for unlimited) + const totalPayments = maxPayments > 0 ? BigInt(maxPayments) : 120n; // ~10 years monthly + const totalAllowance = perPayment * totalPayments; + + const spender = relayerAddr.slice(2).toLowerCase().padStart(64, '0'); + const allowanceHex = totalAllowance.toString(16).padStart(64, '0'); + // approve(address spender, uint256 amount) — selector: 0x095ea7b3 + const calldata = `0x095ea7b3${spender}${allowanceHex}`; + + return { + spender: relayerAddr, + calldata, + totalAllowance: totalAllowance.toString(), + }; +} diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 3a57364..65b897f 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -33,6 +33,9 @@ import { extractProductFromUrl } from './extract'; import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../shared/transak'; import QRCode from 'qrcode'; import { createTransport, type Transporter } from "nodemailer"; +import { + getRelayerAddress, checkAllowance, executeTransferFrom, buildApprovalCalldata, +} from './lib/recurring-executor'; let _syncServer: SyncServer | null = null; @@ -98,7 +101,6 @@ async function checkDueSubscriptions() { if (!_syncServer) return; const now = Date.now(); const transport = getSmtpTransport(); - if (!transport) return; // Scan all payment request docs for due subscriptions const allDocIds = _syncServer.listDocs?.() || []; @@ -113,53 +115,86 @@ async function checkDueSubscriptions() { if (p.status !== 'pending') continue; if (!p.interval || !p.nextDueAt) continue; if (p.nextDueAt > now) continue; - if (!p.subscriberEmail) continue; - // Don't send more than once per interval — check if last payment was recent + // Don't process more than once per interval — check if last payment was recent const lastPayment = p.paymentHistory?.length > 0 ? p.paymentHistory[p.paymentHistory.length - 1] : null; const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000); if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue; - // Send reminder email const space = doc.meta.spaceSlug || 'demo'; const host = 'rspace.online'; const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`; const senderName = p.creatorUsername || 'Someone'; const displayAmount = (!p.amount || p.amount === '0') ? 'a payment' : `${p.amount} ${p.token}`; - const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online'; - const html = ` - - - - -
- - - - -
-

Payment Reminder

-

Your ${p.interval} payment is due

-
-

- Your recurring payment of ${displayAmount} to ${senderName}${p.description ? ` for "${p.description}"` : ''} is due. -

-

Payment ${p.paymentCount + 1}${p.maxPayments > 0 ? ` of ${p.maxPayments}` : ''}

- - -
- - Pay Now - -
-
-

Powered by rSpace · View payment page

-
-
-`; + // ── Attempt automated pull if payer has approved allowance ── + if (p.payerIdentity && p.token !== 'ETH') { + const usdcAddress = USDC_ADDRESSES[p.chainId]; + if (usdcAddress) { + try { + const decimals = p.token === 'USDC' ? 6 : 18; + const txHash = await executeTransferFrom( + usdcAddress, + p.payerIdentity, + p.recipientAddress, + p.amount || '0', + decimals, + p.chainId, + ); + + // Record the automated payment + _syncServer!.changeDoc(docId, 'automated subscription payment', (d) => { + d.payment.paidAt = now; + d.payment.txHash = txHash; + d.payment.paymentMethod = 'wallet'; + d.payment.paymentCount = (d.payment.paymentCount || 0) + 1; + if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any; + (d.payment.paymentHistory as any).push({ + txHash, + transakOrderId: null, + paymentMethod: 'wallet', + payerIdentity: p.payerIdentity, + payerEmail: p.subscriberEmail || null, + amount: d.payment.amount, + paidAt: now, + }); + + if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) { + d.payment.status = 'filled'; + } else { + d.payment.status = 'pending'; + d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval!); + } + d.payment.updatedAt = now; + }); + + console.log(`[rcart] Auto-pulled subscription payment for ${p.id}: ${txHash}`); + + // Send receipt email + if (transport && p.subscriberEmail) { + const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online'; + transport.sendMail({ + from: `"${senderName} via rSpace" <${fromAddr}>`, + to: p.subscriberEmail, + subject: `Payment processed: ${displayAmount} to ${senderName}`, + html: buildReceiptEmail(senderName, displayAmount, txHash, p.chainId, payUrl), + }).catch(err => console.warn(`[rcart] Failed to send receipt for ${p.id}:`, err)); + } + continue; // Payment executed — no reminder needed + } catch (err) { + console.warn(`[rcart] Auto-pull failed for ${p.id}:`, err); + // Fall through to send reminder email instead + } + } + } + + // ── Send reminder email (no auto-pull available or it failed) ── + if (!transport || !p.subscriberEmail) continue; + + const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online'; + const html = buildReminderEmail(senderName, displayAmount, p.interval!, p.paymentCount, p.maxPayments, payUrl); try { await transport.sendMail({ @@ -178,6 +213,66 @@ async function checkDueSubscriptions() { } } +// ── Email template helpers ── + +function buildReminderEmail(sender: string, amount: string, interval: string, count: number, maxPayments: number, payUrl: string): string { + return ` + + + +
+ + + + +
+

Payment Reminder

+

Your ${interval} payment is due

+
+

+ Your recurring payment of ${amount} to ${sender} is due. +

+

Payment ${count + 1}${maxPayments > 0 ? ` of ${maxPayments}` : ''}

+ + +
+ Pay Now +
+
+

Powered by rSpace · View payment page

+
`; +} + +const CHAIN_EXPLORER_URLS: Record = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' }; + +function buildReceiptEmail(sender: string, amount: string, txHash: string, chainId: number, payUrl: string): string { + const explorer = CHAIN_EXPLORER_URLS[chainId]; + const txLink = explorer ? `${txHash.slice(0, 10)}...${txHash.slice(-8)}` : txHash.slice(0, 18) + '...'; + return ` + + + +
+ + + + +
+
+

Subscription Payment Processed

+
+

+ Your recurring payment of ${amount} to ${sender} has been automatically processed. +

+ + + +
Transaction${txLink}
+
+

Powered by rSpace · View subscription

+
`; +} + // ── Automerge helpers ── /** Lazily create (or retrieve) the catalog doc for a space. */ @@ -1655,6 +1750,133 @@ routes.post("/api/payments/:id/share-email", async (c) => { return c.json({ sent, total: validEmails.length }); }); +// ── Recurring payment endpoints ── + +// GET /api/payments/:id/subscription-info — Get subscription approval details +routes.get("/api/payments/:id/subscription-info", 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.paymentType !== 'subscription' && p.paymentType !== 'payer_choice') { + return c.json({ error: "Not a subscription payment" }, 400); + } + + const usdcAddress = USDC_ADDRESSES[p.chainId]; + if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400); + + try { + const relayerAddress = await getRelayerAddress(); + const decimals = p.token === 'USDC' ? 6 : 18; + + // Build the approve calldata for the client + const approval = await buildApprovalCalldata( + p.amount || '0', decimals, p.maxPayments + ); + + return c.json({ + relayerAddress, + tokenAddress: usdcAddress, + chainId: p.chainId, + interval: p.interval, + amountPerPayment: p.amount, + token: p.token, + approveCalldata: approval.calldata, + totalAllowance: approval.totalAllowance, + }); + } catch (e) { + return c.json({ error: "Recurring payments not configured on this server" }, 503); + } +}); + +// POST /api/payments/:id/subscribe — Register a subscription after payer approves allowance +routes.post("/api/payments/:id/subscribe", 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; + const body = await c.req.json(); + const { payerAddress, email, txHash } = body; + + if (!payerAddress) return c.json({ error: "Required: payerAddress" }, 400); + + const usdcAddress = USDC_ADDRESSES[p.chainId]; + if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400); + + // Verify the allowance exists on-chain + try { + const allowance = await checkAllowance(usdcAddress, payerAddress, p.chainId); + const decimals = p.token === 'USDC' ? 6 : 18; + const perPayment = parseTokenAmountServer(p.amount || '0', decimals); + + if (allowance < perPayment) { + return c.json({ + error: "Insufficient allowance. Please approve the relayer to spend your tokens first.", + allowance: allowance.toString(), + required: perPayment.toString(), + }, 400); + } + } catch (e) { + return c.json({ error: "Failed to verify allowance on-chain" }, 500); + } + + // Store subscription info + const now = Date.now(); + _syncServer!.changeDoc(docId, 'register subscription', (d) => { + d.payment.subscriberEmail = email || null; + // Set payer address for automated pulls + d.payment.payerIdentity = payerAddress; + // First due date is one interval from now + if (d.payment.interval) { + d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval); + } + d.payment.updatedAt = now; + }); + + // If this was the first payment (approval tx included a transfer), record it + if (txHash) { + _syncServer!.changeDoc(docId, 'record initial payment', (d) => { + d.payment.status = 'pending'; // Keep accepting + d.payment.paidAt = now; + d.payment.paymentCount = (d.payment.paymentCount || 0) + 1; + d.payment.txHash = txHash; + if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any; + (d.payment.paymentHistory as any).push({ + txHash, + transakOrderId: null, + paymentMethod: 'wallet', + payerIdentity: payerAddress, + payerEmail: email || null, + amount: d.payment.amount, + paidAt: now, + }); + if (d.payment.interval) { + d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval); + } + }); + } + + return c.json({ + subscribed: true, + nextDueAt: doc.payment.nextDueAt ? new Date(doc.payment.nextDueAt).toISOString() : null, + interval: doc.payment.interval, + }); +}); + +// Server-side token amount parser +function parseTokenAmountServer(amount: string, decimals: number): bigint { + const parts = amount.split('.'); + const whole = parts[0] || '0'; + const frac = (parts[1] || '').slice(0, decimals).padEnd(decimals, '0'); + return BigInt(whole) * BigInt(10 ** decimals) + BigInt(frac); +} + // ── Payment success email ── const CHAIN_NAMES: Record = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };