/** * 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(), }; }