233 lines
7.5 KiB
TypeScript
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(),
|
|
};
|
|
}
|