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:
parent
4304170a9a
commit
97a077b256
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,9 @@ import { extractProductFromUrl } from './extract';
|
||||||
import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../shared/transak';
|
import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../shared/transak';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { createTransport, type Transporter } from "nodemailer";
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
|
import {
|
||||||
|
getRelayerAddress, checkAllowance, executeTransferFrom, buildApprovalCalldata,
|
||||||
|
} from './lib/recurring-executor';
|
||||||
|
|
||||||
let _syncServer: SyncServer | null = null;
|
let _syncServer: SyncServer | null = null;
|
||||||
|
|
||||||
|
|
@ -98,7 +101,6 @@ async function checkDueSubscriptions() {
|
||||||
if (!_syncServer) return;
|
if (!_syncServer) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const transport = getSmtpTransport();
|
const transport = getSmtpTransport();
|
||||||
if (!transport) return;
|
|
||||||
|
|
||||||
// Scan all payment request docs for due subscriptions
|
// Scan all payment request docs for due subscriptions
|
||||||
const allDocIds = _syncServer.listDocs?.() || [];
|
const allDocIds = _syncServer.listDocs?.() || [];
|
||||||
|
|
@ -113,53 +115,86 @@ async function checkDueSubscriptions() {
|
||||||
if (p.status !== 'pending') continue;
|
if (p.status !== 'pending') continue;
|
||||||
if (!p.interval || !p.nextDueAt) continue;
|
if (!p.interval || !p.nextDueAt) continue;
|
||||||
if (p.nextDueAt > now) 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
|
const lastPayment = p.paymentHistory?.length > 0
|
||||||
? p.paymentHistory[p.paymentHistory.length - 1]
|
? p.paymentHistory[p.paymentHistory.length - 1]
|
||||||
: null;
|
: null;
|
||||||
const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000);
|
const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000);
|
||||||
if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue;
|
if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue;
|
||||||
|
|
||||||
// Send reminder email
|
|
||||||
const space = doc.meta.spaceSlug || 'demo';
|
const space = doc.meta.spaceSlug || 'demo';
|
||||||
const host = 'rspace.online';
|
const host = 'rspace.online';
|
||||||
const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`;
|
const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`;
|
||||||
const senderName = p.creatorUsername || 'Someone';
|
const senderName = p.creatorUsername || 'Someone';
|
||||||
const displayAmount = (!p.amount || p.amount === '0') ? 'a payment' : `${p.amount} ${p.token}`;
|
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>
|
// ── Attempt automated pull if payer has approved allowance ──
|
||||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
if (p.payerIdentity && p.token !== 'ETH') {
|
||||||
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
const usdcAddress = USDC_ADDRESSES[p.chainId];
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
if (usdcAddress) {
|
||||||
<tr><td align="center">
|
try {
|
||||||
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
const decimals = p.token === 'USDC' ? 6 : 18;
|
||||||
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
const txHash = await executeTransferFrom(
|
||||||
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
|
usdcAddress,
|
||||||
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${p.interval} payment is due</p>
|
p.payerIdentity,
|
||||||
</td></tr>
|
p.recipientAddress,
|
||||||
<tr><td style="background:#1a1a24;padding:32px 24px">
|
p.amount || '0',
|
||||||
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
|
decimals,
|
||||||
Your recurring payment of <strong>${displayAmount}</strong> to <strong>${senderName}</strong>${p.description ? ` for "${p.description}"` : ''} is due.
|
p.chainId,
|
||||||
</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">
|
// Record the automated payment
|
||||||
<tr><td align="center">
|
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'automated subscription payment', (d) => {
|
||||||
<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">
|
d.payment.paidAt = now;
|
||||||
Pay Now
|
d.payment.txHash = txHash;
|
||||||
</a>
|
d.payment.paymentMethod = 'wallet';
|
||||||
</td></tr>
|
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
|
||||||
</table>
|
if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
|
||||||
</td></tr>
|
(d.payment.paymentHistory as any).push({
|
||||||
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
|
txHash,
|
||||||
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace · <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
|
transakOrderId: null,
|
||||||
</td></tr>
|
paymentMethod: 'wallet',
|
||||||
</table>
|
payerIdentity: p.payerIdentity,
|
||||||
</td></tr>
|
payerEmail: p.subscriberEmail || null,
|
||||||
</table>
|
amount: d.payment.amount,
|
||||||
</body></html>`;
|
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 {
|
try {
|
||||||
await transport.sendMail({
|
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 · <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">✓</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 · <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View subscription</a></p>
|
||||||
|
</td></tr>
|
||||||
|
</table></td></tr></table></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Automerge helpers ──
|
// ── Automerge helpers ──
|
||||||
|
|
||||||
/** Lazily create (or retrieve) the catalog doc for a space. */
|
/** 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 });
|
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 ──
|
// ── Payment success email ──
|
||||||
|
|
||||||
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue