webapp-deployment-status-api/src/paymentProcessor.ts
2025-06-26 11:16:55 -04:00

350 lines
11 KiB
TypeScript

import { Registry, Account, parseGasAndFees, DEFAULT_GAS_ESTIMATION_MULTIPLIER } from '@cerc-io/registry-sdk';
import { DeliverTxResponse } from '@cosmjs/stargate';
import { Config } from './config.js';
import { verifyAtomPayment } from './atomPayments.js';
import { RegHelper } from './deployments.js';
import yaml from 'js-yaml';
import fs from 'fs';
interface ChainConfig {
name: string;
rpcUrl: string;
chainId: string;
minAmount: string;
paymentAddress: string;
}
interface VerificationResult {
valid: boolean;
reason?: string;
amount?: string;
sender?: string;
chainUsed?: string;
}
class PaymentProcessor {
private regHelper: RegHelper;
private processedHashes: Set<string> = new Set();
private checkInterval: number = 30000; // 30 seconds
private lastProcessedTime: Date = new Date();
constructor() {
this.regHelper = new RegHelper();
// Only process requests from the last 10 minutes on startup
this.lastProcessedTime = new Date(Date.now() - 10 * 60 * 1000);
}
/**
* Start the payment processor service
*/
start(): void {
console.log('Payment processor started');
this.initializeChains();
this.processPayments();
}
/**
* Main processing loop
*/
private async processPayments(): Promise<void> {
try {
await this.processNewRequests();
} catch (error) {
console.error('Error in payment processing:', error);
}
// Schedule next run
setTimeout(() => this.processPayments(), this.checkInterval);
}
/**
* Process new ApplicationDeploymentRequest records (only recent ones)
*/
private async processNewRequests(): Promise<void> {
try {
// Query for all ApplicationDeploymentRequest records
const allRequests = await this.regHelper.queryRecords({
type: 'ApplicationDeploymentRequest',
});
// Filter to only process recent requests (created after lastProcessedTime)
const recentRequests = allRequests.filter(request => {
const createTime = new Date(request.createTime);
return createTime > this.lastProcessedTime;
});
console.log(`Found ${allRequests.length} total deployment requests, processing ${recentRequests.length} recent ones`);
for (const request of recentRequests) {
await this.processRequest(request);
}
// Update lastProcessedTime to now
this.lastProcessedTime = new Date();
} catch (error) {
console.error('Error querying deployment requests:', error);
}
}
/**
* Process individual deployment request
*/
private async processRequest(request: any): Promise<void> {
const requestId = request.id;
const paymentHash = request.attributes.payment;
// Skip if no payment hash
if (!paymentHash) {
return;
}
// Skip if already processed
if (this.processedHashes.has(paymentHash)) {
return;
}
// Skip if request already has a deployment (already successfully processed)
const existingDeployments = await this.regHelper.queryRecords({
type: 'ApplicationDeploymentRecord',
request: requestId
});
if (existingDeployments.length > 0) {
console.log(`Skipping request ${requestId} - already has deployment`);
this.processedHashes.add(paymentHash);
return;
}
console.log(`Processing request ${requestId} with payment hash ${paymentHash}`);
try {
// First check if this is an LNT payment
const lntVerification = await this.verifyLNTPayment(paymentHash, request);
if (lntVerification.valid) {
console.log(`LNT payment verified for request ${requestId}`);
this.processedHashes.add(paymentHash);
return;
}
// If LNT verification failed, try ATOM verification
const atomVerification = await this.verifyATOMPayment(paymentHash, request);
if (atomVerification.valid) {
console.log(`ATOM payment verified for request ${requestId}, converting to LNT`);
// Send LNT from deployer account to deployer account
await this.sendLNTToDeployer(request);
this.processedHashes.add(paymentHash);
return;
}
// If both failed, log and continue (don't mark as processed to allow retries)
console.log(`Payment verification failed for request ${requestId}: LNT: ${lntVerification.reason}, ATOM: ${atomVerification.reason}`);
} catch (error) {
console.error(`Error processing request ${requestId}:`, error);
}
}
/**
* Verify LNT payment - delegated to stack-orchestrator
* Since stack-orchestrator already handles LNT verification efficiently,
* we only need to handle ATOM verification here
*/
private async verifyLNTPayment(_txHash: string, _request: any): Promise<VerificationResult> {
try {
// LNT verification is handled by stack-orchestrator
// We assume if it's not an ATOM payment, stack-orchestrator will handle it
return {
valid: false,
reason: 'LNT verification delegated to stack-orchestrator'
};
} catch (error) {
return {
valid: false,
reason: `LNT verification error: ${error.message}`
};
}
}
/**
* Verify ATOM payment with 5-minute time window
*/
private async verifyATOMPayment(txHash: string, request: any): Promise<VerificationResult> {
try {
// Skip if ATOM payment not configured
if (!Config.ATOM_PAYMENT_ADDRESS || !Config.COSMOS_RPC_ENDPOINT) {
return {
valid: false,
reason: 'ATOM payment verification not configured'
};
}
// Get the deployer record to check ATOM payment config
const deployer = await this.regHelper.getRecord(request.attributes.deployer);
if (!deployer) {
return {
valid: false,
reason: 'Deployer record not found'
};
}
const atomPaymentAddress = deployer.attributes.atomPaymentAddress || Config.ATOM_PAYMENT_ADDRESS;
const minAtomPayment = deployer.attributes.minimumAtomPayment || Config.MIN_ATOM_PAYMENT;
if (!atomPaymentAddress) {
return {
valid: false,
reason: 'ATOM payment address not configured for this deployer'
};
}
// Use existing ATOM verification with 5-minute time window
const result = await verifyAtomPayment(txHash, minAtomPayment, false);
if (result.valid) {
return {
valid: true,
amount: result.amount,
sender: result.sender,
chainUsed: 'ATOM'
};
} else {
return {
valid: false,
reason: result.reason || 'ATOM payment verification failed'
};
}
} catch (error) {
return {
valid: false,
reason: `ATOM verification error: ${error.message}`
};
}
}
/**
* Send LNT from deployer account to deployer account to satisfy stack-orchestrator
*/
private async sendLNTToDeployer(request: any): Promise<void> {
try {
// Get the deployer record
const deployer = await this.regHelper.getRecord(request.attributes.deployer);
if (!deployer) {
throw new Error('Deployer record not found');
}
const paymentAddress = deployer.attributes.paymentAddress;
const minPayment = deployer.attributes.minimumPayment || '0alnt';
// Extract amount number from string like "100alnt"
const amount = minPayment.replace(/[^0-9]/g, '') || '0';
console.log(`Sending ${minPayment} LNT from deployer to deployer (${paymentAddress}) for request ${request.id}`);
// Send LNT tokens using proper Registry SDK methods
const result = await this.sendLNTTokens(paymentAddress, amount);
console.log(`LNT transfer successful: ${result.transactionHash}`);
} catch (error) {
console.error(`Error sending LNT for request ${request.id}:`, error);
throw error;
}
}
/**
* Send LNT tokens using Registry SDK
*/
private async sendLNTTokens(toAddress: string, amount: string): Promise<DeliverTxResponse> {
// Load laconic config to get registry details and private key
const configData = yaml.load(fs.readFileSync(Config.LACONIC_CONFIG, 'utf8')) as any;
const registryConfig = configData.services?.registry;
if (!registryConfig) {
throw new Error('Registry configuration not found');
}
// Get private key from environment or config
const privateKey = process.env.REGISTRY_USER_KEY || registryConfig.privateKey;
if (!privateKey) {
throw new Error('Registry private key not configured');
}
// Create account
const account = new Account(Buffer.from(privateKey, 'hex'));
await account.init();
// Create registry instance
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.rpcEndpoint,
{ chainId: registryConfig.chainId }
);
// Get laconic client
const laconicClient = await registry.getLaconicClient(account);
// Calculate fees
const fee = registryConfig.gas && registryConfig.fees ?
parseGasAndFees(registryConfig.gas, registryConfig.fees) :
DEFAULT_GAS_ESTIMATION_MULTIPLIER;
// Send tokens from deployer account to deployer account
const txResponse: DeliverTxResponse = await laconicClient.sendTokens(
account.address, // from (deployer account)
toAddress, // to (same deployer account)
[{
denom: 'alnt', // LNT token denomination
amount // amount as string
}],
fee
);
return txResponse;
}
/**
* Get chain configurations for future extensibility
*/
private getChainConfigs(): ChainConfig[] {
const configs: ChainConfig[] = [];
// ATOM configuration
if (Config.ATOM_PAYMENT_ADDRESS && Config.COSMOS_RPC_ENDPOINT) {
configs.push({
name: 'ATOM',
rpcUrl: Config.COSMOS_RPC_ENDPOINT,
chainId: 'cosmoshub-4', // Could be made configurable
minAmount: Config.MIN_ATOM_PAYMENT,
paymentAddress: Config.ATOM_PAYMENT_ADDRESS,
});
}
// Future chains can be added here
// if (Config.OSMO_PAYMENT_ADDRESS && Config.OSMO_RPC_ENDPOINT) {
// configs.push({
// name: 'OSMO',
// rpcUrl: Config.OSMO_RPC_ENDPOINT,
// chainId: 'osmosis-1',
// minAmount: Config.MIN_OSMO_PAYMENT,
// paymentAddress: Config.OSMO_PAYMENT_ADDRESS,
// });
// }
console.log(`Configured ${configs.length} payment chains`);
return configs;
}
/**
* Initialize chain configurations on startup
*/
private initializeChains(): void {
const configs = this.getChainConfigs();
console.log(`Payment processor initialized with ${configs.length} supported chains`);
}
}
export { PaymentProcessor };