458 lines
15 KiB
TypeScript
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 });
|
|
}
|
|
} |