feat(rcart): Layer 3 — automated recurring payment execution via ERC-20 allowance

Recurring Executor (new module):
- Server-managed relayer keypair derived deterministically via HKDF
- Checks on-chain ERC-20 allowance before pulling funds
- Executes transferFrom when subscriptions are due
- Supports Base, Base Sepolia, and Ethereum mainnet

New API Endpoints:
- GET /api/payments/:id/subscription-info — returns relayer address and
  approve calldata for the client to authorize recurring pulls
- POST /api/payments/:id/subscribe — registers a subscription after
  payer approves on-chain allowance, verifies allowance exists

Scheduler Upgrade:
- Attempts automated pull first for subscriptions with approved allowance
- Falls back to email reminder if auto-pull fails or is not configured
- Sends branded receipt email after successful automated payments
- Extracted email templates into reusable helper functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 21:32:13 +00:00
parent 4304170a9a
commit 97a077b256
2 changed files with 489 additions and 35 deletions

View File

@ -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<number, Chain> = {
8453: base,
84532: baseSepolia,
1: mainnet,
};
const RPC_URLS: Record<number, string> = {
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<string> {
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<bigint> {
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<string> {
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(),
};
}

View File

@ -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 = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${p.interval} payment is due</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
Your recurring payment of <strong>${displayAmount}</strong> to <strong>${senderName}</strong>${p.description ? ` for "${p.description}"` : ''} is due.
</p>
<p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${p.paymentCount + 1}${p.maxPayments > 0 ? ` of ${p.maxPayments}` : ''}</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
Pay Now
</a>
</td></tr>
</table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
// ── 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<PaymentRequestDoc>(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 `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${interval} payment is due</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
Your recurring payment of <strong>${amount}</strong> to <strong>${sender}</strong> is due.
</p>
<p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${count + 1}${maxPayments > 0 ? ` of ${maxPayments}` : ''}</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">Pay Now</a>
</td></tr>
</table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table></td></tr></table></body></html>`;
}
const CHAIN_EXPLORER_URLS: Record<number, string> = { 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 ? `<a href="${explorer}${txHash}" style="color:#67e8f9;text-decoration:none">${txHash.slice(0, 10)}...${txHash.slice(-8)}</a>` : txHash.slice(0, 18) + '...';
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<div style="font-size:48px;margin-bottom:8px">&#10003;</div>
<h1 style="color:#fff;font-size:24px;margin:0">Subscription Payment Processed</h1>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
Your recurring payment of <strong>${amount}</strong> to <strong>${sender}</strong> has been automatically processed.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:16px">
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Transaction</td>
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${txLink}</td></tr>
</table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View subscription</a></p>
</td></tr>
</table></td></tr></table></body></html>`;
}
// ── 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<PaymentRequestDoc>(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<PaymentRequestDoc>(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<PaymentRequestDoc>(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<PaymentRequestDoc>(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<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };