rspace-online/modules/rcart/lib/recurring-executor.ts

233 lines
7.5 KiB
TypeScript

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