gor-deploy/src/app/api/registry/route.ts
2025-07-21 17:53:09 +05:30

458 lines
15 KiB
TypeScript

import BN from 'bn.js';
import { NextRequest, NextResponse } from 'next/server';
import axios from 'axios';
import assert from 'assert';
import { GasPrice } from '@cosmjs/stargate';
import { Connection } from '@solana/web3.js';
import { DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
import { verifyUnusedSolanaPayment } from '@/utils/solanaVerify';
import { transferLNTTokens } from '@/services/laconicTransfer';
import { getRegistry, getRegistryConfig } from '@/config';
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
// Use CAIP convention for chain ID: namespace + reference
const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // Solana mainnet
// Sleep helper function
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Extract repo name from URL
const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, provider: string } => {
try {
const parsedUrl = new URL(url);
const pathParts = parsedUrl.pathname.split('/').filter(part => part);
// GitHub repository URL pattern
if (parsedUrl.hostname === 'github.com' && pathParts.length >= 2) {
return {
repoName: pathParts[1],
repoUrl: `https://github.com/${pathParts[0]}/${pathParts[1]}`,
provider: 'github'
};
}
// GitLab repository URL pattern
if ((parsedUrl.hostname === 'gitlab.com' || parsedUrl.hostname.includes('gitlab')) && pathParts.length >= 2) {
return {
repoName: pathParts[pathParts.length - 1],
repoUrl: url,
provider: 'gitlab'
};
}
// Bitbucket repository URL pattern
if (parsedUrl.hostname === 'bitbucket.org' && pathParts.length >= 2) {
return {
repoName: pathParts[1],
repoUrl: `https://bitbucket.org/${pathParts[0]}/${pathParts[1]}`,
provider: 'bitbucket'
};
}
// For other URLs, try to extract a meaningful name from the hostname
const hostnameWithoutTLD = parsedUrl.hostname.split('.')[0];
return {
repoName: hostnameWithoutTLD,
repoUrl: url,
provider: 'other'
};
} catch (error) {
console.warn('Failed to parse URL, using fallback name', error);
return {
repoName: 'webapp',
repoUrl: url,
provider: 'other'
};
}
};
// Fetch latest commit hash from GitHub repository
const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise<{ fullHash: string, shortHash: string }> => {
try {
// Handle GitHub repositories
if (provider === 'github') {
// Extract owner and repo from GitHub URL
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) {
const [, owner, repo] = match;
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/main`;
const response = await axios.get(apiUrl);
if (response.data && response.data.sha) {
// Return both full hash and short hash (7 characters)
return {
fullHash: response.data.sha,
shortHash: response.data.sha.substring(0, 7)
};
}
}
}
// For non-GitHub repositories or if fetching fails, return a default value
return {
fullHash: 'main',
shortHash: 'main'
};
} catch (error) {
console.warn('Failed to fetch latest commit hash:', error);
return {
fullHash: 'main',
shortHash: 'main'
};
}
};
// Registry transaction retry helper
export const registryTransactionWithRetry = async (
txFn: () => Promise<unknown>,
maxRetries = 3,
delay = 1000
): Promise<unknown> => {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await txFn();
} catch (error) {
console.error(`Transaction attempt ${attempt + 1} failed:`, error);
lastError = error;
if (attempt < maxRetries - 1) {
await sleep(delay);
}
}
}
throw lastError;
};
let connection: Connection;
export async function POST(request: NextRequest) {
try {
if (!connection) {
connection = new Connection(SOLANA_RPC_URL);
}
// First check if the request body is valid JSON
let url, txHash, senderPublicKey;
try {
const body = await request.json();
url = body.url;
txHash = body.txHash;
const tx = await connection.getParsedTransaction(txHash, 'confirmed');
if (!tx) {
console.error("Transaction not found.");
return NextResponse.json({
status: 'error',
message: 'Invalid tx hash'
}, { status: 400 });
}
const signerKeys = tx.transaction.message.accountKeys
.filter(k => k.signer)
.map(k => k.pubkey.toBase58());
senderPublicKey = signerKeys[0];
if (!url || !txHash) {
return NextResponse.json({
status: 'error',
message: 'Missing required fields: url and txHash are required'
}, { status: 400 });
}
} catch (error) {
console.error(error);
return NextResponse.json({
status: 'error',
message: 'Invalid JSON in request body'
}, { status: 400 });
}
// Verify Solana payment
console.log('Step 0: Verifying Solana token payment...');
const paymentAmount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!);
const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!);
const fullAmount = paymentAmount * Math.pow(10, decimals);
const tokenAmount = new BN(fullAmount);
const solanaPaymentResult = await verifyUnusedSolanaPayment(connection, txHash, tokenAmount);
if (!solanaPaymentResult.valid) {
console.error('Solana token payment verification failed:', solanaPaymentResult.reason);
return NextResponse.json({
status: 'error',
message: `Payment verification failed: ${solanaPaymentResult.reason}`
}, { status: 400 });
}
console.log('Solana token payment verified successfully:', {
amount: solanaPaymentResult.amount,
sender: solanaPaymentResult.sender
});
// Perform LNT transfer after payment verification
console.log('Step 0.5: Performing LNT transfer from prefilled account to service provider...');
const lntTransferResult = await transferLNTTokens();
if (!lntTransferResult.success) {
console.error('LNT transfer failed:', lntTransferResult.error);
return NextResponse.json({
status: 'error',
message: `LNT transfer failed: ${lntTransferResult.error}`
}, { status: 500 });
}
console.log('LNT transfer completed:', lntTransferResult.transactionHash);
const finalTxHash = lntTransferResult.transactionHash!; // Use LNT transfer hash for registry
// Validate required environment variables for Solana payments
const requiredEnvVars = [
'REGISTRY_CHAIN_ID',
'REGISTRY_GQL_ENDPOINT',
'REGISTRY_RPC_ENDPOINT',
'REGISTRY_BOND_ID',
'REGISTRY_AUTHORITY',
'REGISTRY_USER_KEY', // This is the same as the prefilled account for LNT transfers
'DEPLOYER_LRN',
'NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS',
'NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS'
];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error(`Missing environment variable: ${envVar}`);
return NextResponse.json({
status: 'error',
message: `Server configuration error: Missing environment variable: ${envVar}`
}, { status: 500 });
}
}
// Extract repository information from URL
const { repoName, repoUrl, provider } = extractRepoInfo(url);
console.log(`Extracted repo info - Name: ${repoName}, URL: ${repoUrl}, Provider: ${provider}`);
// Fetch latest commit hash (or default to 'main' if unable to fetch)
const { fullHash, shortHash } = await fetchLatestCommitHash(repoUrl, provider);
console.log(`Using commit hash - Full: ${fullHash}, Short: ${shortHash}`);
// Use the repository name as the app name
const appName = repoName;
console.log(`Using app name: ${appName}`);
// Sanitize the app name to ensure it's DNS-compatible (only alphanumeric and dashes)
const sanitizedAppName = appName.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
// Create DNS name in format: app_name-shortcommithash
const dnsName = `${sanitizedAppName}-${shortHash}`;
console.log(`DNS name: ${dnsName} (sanitized from: ${appName})`);
// Ensure the DNS name doesn't have consecutive dashes or start/end with a dash
let cleanDnsName = dnsName
.replace(/--+/g, '-') // Replace consecutive dashes with a single dash
.replace(/^-+|-+$/g, ''); // Remove leading and trailing dashes
// Ensure DNS name is valid (63 chars max per label, all lowercase, starts with a letter)
if (cleanDnsName.length > 63) {
// If too long, truncate but preserve both the commit hash
const maxAppNameLength = 63 - shortHash.length;
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + shortHash;
}
// If the DNS name ended up empty (unlikely) or doesn't start with a letter (possible),
// add a prefix to make it valid
if (!cleanDnsName || !/^[a-z]/.test(cleanDnsName)) {
cleanDnsName = `app-${cleanDnsName}`;
}
console.log(`Final DNS name: ${cleanDnsName}`);
// Set up Registry config
const config = getRegistryConfig()
console.log('Registry config:', {
...config,
privateKey: '[REDACTED]', // Don't log the private key
});
const deployerLrn = process.env.DEPLOYER_LRN!;
// Create Registry client instance
const gasPrice = GasPrice.fromString(config.fee.gasPrice + ALNT_DENOM);
console.log('Using manual gas price:', gasPrice);
const registry = getRegistry()
// Create LRN for the application with commit hash
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}`;
// Get current timestamp for the meta note
const timestamp = new Date().toUTCString();
// Step 1: Create and publish ApplicationRecord first
console.log('Step 1: Publishing ApplicationRecord...');
const applicationRecord = {
type: 'ApplicationRecord',
name: `${appName}-${shortHash}`, // Include commit hash in the record name
version: '1.0.0',
app_type: 'webapp',
repository: [repoUrl],
repository_ref: fullHash,
app_version: '0.0.1'
};
console.log('Application record data:', applicationRecord);
// Publish the application record
let applicationRecordId;
try {
const appRecordResult = await registryTransactionWithRetry(() =>
registry.setRecord(
{
privateKey: config.privateKey,
record: applicationRecord,
bondId: config.bondId,
},
config.privateKey,
)
) as { id?: string };
applicationRecordId = appRecordResult.id;
console.log('Application record published with ID:', applicationRecordId);
if (!applicationRecordId) {
return NextResponse.json({
status: 'error',
message: 'Failed to publish ApplicationRecord'
}, { status: 500 });
}
} catch (err) {
console.error('Error publishing ApplicationRecord:', err);
return NextResponse.json({
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error publishing ApplicationRecord'
}, { status: 500 });
}
// Step 2: Set name mappings
console.log('Step 2: Setting name mappings...');
try {
// Set the main LRN to point to the application record
await registryTransactionWithRetry(() =>
registry.setName(
{
cid: applicationRecordId,
lrn
},
config.privateKey,
)
);
console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`);
// Set the versioned LRN (with repository_ref)
await registryTransactionWithRetry(() =>
registry.setName(
{
cid: applicationRecordId,
lrn: `${lrn}@${fullHash}`
},
config.privateKey,
)
);
console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`);
} catch (err) {
console.error('Error setting name mappings:', err);
return NextResponse.json({
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error setting name mappings'
}, { status: 500 });
}
// Step 3: Create ApplicationDeploymentRequest
console.log('Step 3: Creating ApplicationDeploymentRequest...');
// Prepare record data for deployment request
const deploymentRequestData = {
type: 'ApplicationDeploymentRequest',
version: '1.0.0',
name: `${appName}-${shortHash}`, // Update name to match application record
application: lrn, // LRN already includes commit hash
deployer: deployerLrn,
dns: cleanDnsName,
config: {
// TODO: Add feature to set env config for app
env: {}
},
meta: {
note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`,
repository: repoUrl,
repository_ref: fullHash,
external_payment: {
chain_id: SOLANA_CHAIN_ID,
tx_hash: txHash,
pubkey: senderPublicKey
}
},
payment: finalTxHash,
};
console.log('Deployment request data:', deploymentRequestData);
// Publish the deployment request
let deploymentRequestId;
try {
const deployRequestResult = await registryTransactionWithRetry(() =>
registry.setRecord(
{
privateKey: config.privateKey,
record: deploymentRequestData,
bondId: config.bondId,
},
config.privateKey,
)
) as { id?: string };
deploymentRequestId = deployRequestResult.id;
console.log('Deployment request published with ID:', deploymentRequestId);
if (!deploymentRequestId) {
return NextResponse.json({
status: 'error',
message: 'Failed to publish ApplicationDeploymentRequest'
}, { status: 500 });
}
} catch (err) {
console.error('Error publishing deployment request:', err);
return NextResponse.json({
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error publishing deployment request'
}, { status: 500 });
}
// Return combined results
return NextResponse.json({
id: deploymentRequestId,
applicationRecordId: applicationRecordId,
status: 'success',
lrn: lrn,
dns: cleanDnsName,
appName: appName,
repoUrl: repoUrl,
commitHash: fullHash,
shortCommitHash: shortHash
});
} catch (error) {
console.error('Failed to create application deployment request:', error);
return NextResponse.json({
status: 'error',
message: error instanceof Error ? error.message : 'Unknown error',
}, { status: 500 });
}
}