diff --git a/src/atomPayments.ts b/src/atomPayments.ts index c6dd356..d7d176f 100644 --- a/src/atomPayments.ts +++ b/src/atomPayments.ts @@ -29,7 +29,8 @@ setInterval(() => { export async function verifyAtomPayment( txHash: string, requiredAmount = Config.MIN_ATOM_PAYMENT, - markAsUsed = false + markAsUsed = false, + timeWindowMinutes = 5 ): Promise<{ valid: boolean, reason?: string, @@ -100,6 +101,19 @@ export async function verifyAtomPayment( }; } + // Check transaction timestamp (5-minute window) + const txTimestamp = new Date(txResponse.timestamp); + const now = new Date(); + const timeDiffMs = now.getTime() - txTimestamp.getTime(); + const timeWindowMs = timeWindowMinutes * 60 * 1000; + + if (timeDiffMs > timeWindowMs) { + return { + valid: false, + reason: `Transaction is older than ${timeWindowMinutes} minutes (${Math.round(timeDiffMs / 60000)} minutes old)` + }; + } + // Extract the payment details const tx = response.data.tx; let foundValidPayment = false; diff --git a/src/config.ts b/src/config.ts index 8edbed8..5849df7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ import yaml from 'js-yaml'; import fs from 'fs'; import path from 'path'; -import {Registry} from '@cerc-io/laconic-sdk'; +import {Registry} from '@cerc-io/registry-sdk'; const loadConfigFile = (configFilePath: string): any => { const resolvedFilePath = path.resolve(process.cwd(), configFilePath); diff --git a/src/deployments.ts b/src/deployments.ts index 0437e6a..f8e1cfe 100644 --- a/src/deployments.ts +++ b/src/deployments.ts @@ -1,5 +1,5 @@ import {getRegistry, Config} from './config.js'; -import {Registry} from '@cerc-io/laconic-sdk'; +import {Registry} from '@cerc-io/registry-sdk'; import stringify from 'json-stable-stringify'; import {createHash} from 'crypto'; @@ -110,44 +110,8 @@ export class RegHelper { return [...records]; } - async verifyAtomPaymentForRequest(request: any, deployer: any) { - // Skip if no ATOM payment configuration - if (!Config.ATOM_PAYMENT_ADDRESS || !Config.COSMOS_RPC_ENDPOINT) { - console.log(`ATOM payment verification skipped: Missing configuration`); - return false; - } - - // Skip if no payment transaction - if (!request.attributes.payment) { - console.log(`Request ${request.id}: No payment transaction hash provided`); - return false; - } - - try { - // Check if deployer has atom payment config - const atomPaymentAddress = deployer.attributes.atomPaymentAddress || Config.ATOM_PAYMENT_ADDRESS; - const minAtomPayment = deployer.attributes.minimumAtomPayment || Config.MIN_ATOM_PAYMENT; - - if (!atomPaymentAddress) { - console.log(`Request ${request.id}: No ATOM payment address configured`); - return false; - } - - console.log(`Verifying ATOM payment for request ${request.id} - tx: ${request.attributes.payment}`); - const verification = await verifyAtomPayment(request.attributes.payment, minAtomPayment); - - if (verification.valid) { - console.log(`ATOM payment verified for request ${request.id} - Amount: ${verification.amount}`); - return true; - } else { - console.log(`ATOM payment verification failed for request ${request.id}: ${verification.reason}`); - return false; - } - } catch (error) { - console.error(`Error verifying ATOM payment for request ${request.id}:`, error); - return false; - } - } + // ATOM payment verification moved to background payment processor + // This method is no longer used to avoid blocking the status API async deploymentRequestStatus(requestId?: string) { const requests: any[] = []; @@ -156,10 +120,8 @@ export class RegHelper { type: 'ApplicationDeploymentRemovalRequest', }); - // Get all WebappDeployer records to check for atom payment configuration - const deployers = await this.queryRecords({ - type: 'WebappDeployer', - }); + // WebappDeployer records no longer needed for status display + // Payment verification is handled by background processor if (requestId) { const request = await this.getRecordById(requestId); @@ -180,16 +142,6 @@ export class RegHelper { requests.sort((a, b) => a.createTime === b.createTime ? 0 : a.createTime > b.createTime ? 1 : -1,); requests.reverse(); - - // Create a map of deployers by their LRN for quick lookup - const deployersByLRN = new Map(); - for (const deployer of deployers) { - if (deployer.names && deployer.names.length > 0) { - for (const name of deployer.names) { - deployersByLRN.set(name, deployer); - } - } - } const deploymentsByRequest = new Map(); for (const d of deployments) { @@ -218,24 +170,11 @@ export class RegHelper { status.app = r.attributes.application; const hostname = r.attributes.dns ?? generateHostnameForApp(app); - // Mark as ATOM payment verified if applicable - if (r.attributes.payment && !deploymentsByRequest.has(r.id) && !removalsByRequest.has(r.id)) { - // Find the deployer record for this request - const deployerLRN = r.attributes.deployer; - if (deployerLRN && deployersByLRN.has(deployerLRN)) { - const deployer = deployersByLRN.get(deployerLRN); - - // Check if this is a valid ATOM payment - if (deployer.attributes.atomPaymentAddress || Config.ATOM_PAYMENT_ADDRESS) { - // Just verify but don't mark as used since this is just for status display - const isValidAtomPayment = await this.verifyAtomPaymentForRequest(r, deployer); - - if (isValidAtomPayment) { - // If the payment is valid, mark it in the status - status.atomPaymentVerified = true; - } - } - } + // Payment verification is now handled by the background payment processor + // No longer blocking the status API with external verification calls + if (r.attributes.payment) { + // Just indicate that there is a payment hash, actual verification is done in background + status.atomPaymentVerified = false; // Will be updated by payment processor if needed } if (deploymentsByRequest.has(r.id)) { diff --git a/src/main.ts b/src/main.ts index f9ab2fd..ee24c71 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import {Config} from './config.js'; import {RegHelper} from './deployments.js'; import {Uploader} from './upload.js'; import {verifyAtomPayment} from './atomPayments.js'; +import {PaymentProcessor} from './paymentProcessor.js'; const app = express(); app.use(express.json()); @@ -113,7 +114,7 @@ app.post('/upload/config', configUploadParser, async (req, res) => { } }); -// API endpoint to verify ATOM payments +// API endpoint to verify ATOM payments (with timeout to prevent blocking) app.post('/verify/atom-payment', async (req, res) => { try { const { txHash, minAmount, markAsUsed } = req.body; @@ -140,7 +141,15 @@ app.post('/verify/atom-payment', async (req, res) => { } const requiredAmount = minAmount || Config.MIN_ATOM_PAYMENT; - const result = await verifyAtomPayment(txHash, requiredAmount, Boolean(markAsUsed)); + + // Add timeout to prevent blocking + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Verification timeout')), 10000) + ); + + const verificationPromise = verifyAtomPayment(txHash, requiredAmount, Boolean(markAsUsed), 5); + + const result = await Promise.race([verificationPromise, timeoutPromise]); res.json(result); } catch (e) { @@ -168,6 +177,11 @@ app.get('/log/:id', async (req, res) => { } }); +// Start the payment processor service +const paymentProcessor = new PaymentProcessor(); +paymentProcessor.start(); + app.listen(Config.LISTEN_PORT, Config.LISTEN_ADDR, () => { console.log(`listening on ${Config.LISTEN_ADDR}:${Config.LISTEN_PORT}`); + console.log('Payment processor service started'); }); diff --git a/src/paymentProcessor.ts b/src/paymentProcessor.ts new file mode 100644 index 0000000..0a55625 --- /dev/null +++ b/src/paymentProcessor.ts @@ -0,0 +1,366 @@ +import { Registry, Account, parseGasAndFees, DEFAULT_GAS_ESTIMATION_MULTIPLIER } from '@cerc-io/registry-sdk'; +import { DeliverTxResponse } from '@cosmjs/stargate'; +import { getRegistry, 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 = new Set(); + private checkInterval: number = 30000; // 30 seconds + + constructor() { + this.regHelper = new RegHelper(); + } + + /** + * Start the payment processor service + */ + start(): void { + console.log('Payment processor started'); + this.processPayments(); + } + + /** + * Main processing loop + */ + private async processPayments(): Promise { + 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 + */ + private async processNewRequests(): Promise { + try { + // Query for all ApplicationDeploymentRequest records + const requests = await this.regHelper.queryRecords({ + type: 'ApplicationDeploymentRequest', + }); + + console.log(`Found ${requests.length} deployment requests`); + + for (const request of requests) { + await this.processRequest(request); + } + } catch (error) { + console.error('Error querying deployment requests:', error); + } + } + + /** + * Process individual deployment request + */ + private async processRequest(request: any): Promise { + 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; + } + + 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 using existing laconic registry + */ + private async verifyLNTPayment(txHash: string, request: any): Promise { + try { + // Use the registry from regHelper to get transaction + const registry = getRegistry(); + const tx = await registry.getTx(txHash); + + if (!tx) { + return { + valid: false, + reason: 'Transaction not found in laconic registry' + }; + } + + if (tx.code !== 0) { + return { + valid: false, + reason: `Transaction failed with code ${tx.code}: ${tx.log}` + }; + } + + // Get the deployer record to check payment address + const deployer = await this.regHelper.getRecord(request.attributes.deployer); + if (!deployer) { + return { + valid: false, + reason: 'Deployer record not found' + }; + } + + const paymentAddress = deployer.attributes.paymentAddress; + const minPayment = parseInt(deployer.attributes.minimumPayment?.replace(/[^0-9]/g, '') || '0'); + + if (tx.recipient !== paymentAddress) { + return { + valid: false, + reason: `Payment recipient ${tx.recipient} does not match deployer payment address ${paymentAddress}` + }; + } + + const payDenom = tx.amount.replace(/[0-9]/g, ''); + if (payDenom !== 'alnt') { + return { + valid: false, + reason: `Payment denomination ${payDenom} is not 'alnt'` + }; + } + + const payAmount = parseInt(tx.amount.replace(/[^0-9]/g, '')); + if (payAmount < minPayment) { + return { + valid: false, + reason: `Payment amount ${payAmount} is less than minimum ${minPayment}` + }; + } + + return { + valid: true, + amount: tx.amount, + sender: tx.sender, + chainUsed: 'LNT' + }; + + } 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 { + 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 { + 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 { + // 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, + // }); + // } + + return configs; + } +} + +export { PaymentProcessor }; \ No newline at end of file