Use solana GOR token payments to deploy apps (#1)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949 Co-authored-by: Shreerang Kale <shreerangkale@gmail.com> Co-authored-by: Nabarun <nabarun@deepstacksoft.com> Reviewed-on: #1 Co-authored-by: shreerang <shreerang@noreply.git.vdb.to> Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
This commit was merged in pull request #1.
This commit is contained in:
+155
-238
@@ -1,135 +1,40 @@
|
||||
import BN from 'bn.js';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Account, Registry, parseGasAndFees } from '@cerc-io/registry-sdk';
|
||||
import { GasPrice } from '@cosmjs/stargate';
|
||||
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));
|
||||
|
||||
// ATOM payment verification function
|
||||
const verifyAtomPayment = async (txHash: string): Promise<{
|
||||
valid: boolean,
|
||||
reason?: string,
|
||||
amount?: string,
|
||||
sender?: string
|
||||
}> => {
|
||||
try {
|
||||
const apiEndpoint = process.env.NEXT_PUBLIC_COSMOS_API_URL;
|
||||
const recipientAddress = process.env.NEXT_PUBLIC_RECIPIENT_ADDRESS;
|
||||
const minPaymentUAtom = '100000'; // 0.1 ATOM in uatom
|
||||
|
||||
if (!apiEndpoint) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'ATOM API endpoint not configured'
|
||||
};
|
||||
}
|
||||
|
||||
if (!recipientAddress) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'ATOM recipient address not configured'
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch transaction from the ATOM API endpoint
|
||||
const response = await axios.get(`${apiEndpoint}/cosmos/tx/v1beta1/txs/${txHash}`);
|
||||
|
||||
if (!response.data || !response.data.tx || !response.data.tx_response) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Invalid transaction data from API endpoint'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if transaction was successful
|
||||
const txResponse = response.data.tx_response;
|
||||
if (txResponse.code !== 0) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Transaction failed with code ${txResponse.code}: ${txResponse.raw_log}`
|
||||
};
|
||||
}
|
||||
|
||||
// Check transaction timestamp (5-minute window)
|
||||
const txTimestamp = new Date(txResponse.timestamp);
|
||||
const now = new Date();
|
||||
const timeDiffMs = now.getTime() - txTimestamp.getTime();
|
||||
const timeWindowMs = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (timeDiffMs > timeWindowMs) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Transaction is older than 5 minutes (${Math.round(timeDiffMs / 60000)} minutes old)`
|
||||
};
|
||||
}
|
||||
|
||||
// Extract the payment details
|
||||
const tx = response.data.tx;
|
||||
let foundValidPayment = false;
|
||||
let paymentAmountUAtom = '';
|
||||
let sender = '';
|
||||
|
||||
// Get the sender address from the first signer
|
||||
if (tx.auth_info && tx.auth_info.signer_infos && tx.auth_info.signer_infos.length > 0) {
|
||||
sender = tx.auth_info.signer_infos[0].public_key.address || '';
|
||||
}
|
||||
|
||||
// Find the send message in the transaction
|
||||
for (const msg of tx.body.messages) {
|
||||
if (msg['@type'] === '/cosmos.bank.v1beta1.MsgSend') {
|
||||
if (msg.to_address === recipientAddress) {
|
||||
for (const coin of msg.amount) {
|
||||
if (coin.denom === 'uatom') {
|
||||
// Get the amount in uatom
|
||||
paymentAmountUAtom = coin.amount;
|
||||
|
||||
if (parseInt(paymentAmountUAtom) >= parseInt(minPaymentUAtom)) {
|
||||
foundValidPayment = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundValidPayment) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Payment amount (${paymentAmountUAtom || '0'}uatom) is less than required (${minPaymentUAtom}uatom) or not sent to the correct address (${recipientAddress})`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
amount: `${paymentAmountUAtom}uatom`,
|
||||
sender
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error verifying ATOM payment:', error);
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Failed to verify transaction: ${error.message || 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 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],
|
||||
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 {
|
||||
@@ -138,7 +43,7 @@ const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, prov
|
||||
provider: 'gitlab'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Bitbucket repository URL pattern
|
||||
if (parsedUrl.hostname === 'bitbucket.org' && pathParts.length >= 2) {
|
||||
return {
|
||||
@@ -147,7 +52,7 @@ const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, prov
|
||||
provider: 'bitbucket'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// For other URLs, try to extract a meaningful name from the hostname
|
||||
const hostnameWithoutTLD = parsedUrl.hostname.split('.')[0];
|
||||
return {
|
||||
@@ -175,7 +80,7 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
|
||||
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)
|
||||
@@ -186,7 +91,7 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For non-GitHub repositories or if fetching fails, return a default value
|
||||
return {
|
||||
fullHash: 'main',
|
||||
@@ -202,202 +107,214 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
|
||||
};
|
||||
|
||||
// Registry transaction retry helper
|
||||
const registryTransactionWithRetry = async (
|
||||
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;
|
||||
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'
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
message: 'Missing required fields: url and txHash are required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
message: 'Invalid JSON in request body'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// First, verify the ATOM payment before doing anything else
|
||||
console.log('Step 0: Verifying ATOM payment...');
|
||||
const paymentVerificationResult = await verifyAtomPayment(txHash);
|
||||
|
||||
if (!paymentVerificationResult.valid) {
|
||||
console.error('ATOM payment verification failed:', paymentVerificationResult.reason);
|
||||
console.error(error);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
message: `Payment verification failed: ${paymentVerificationResult.reason}`
|
||||
message: 'Invalid JSON in request body'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('ATOM payment verified successfully:', {
|
||||
amount: paymentVerificationResult.amount,
|
||||
sender: paymentVerificationResult.sender
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
// Validate required environment variables
|
||||
// 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',
|
||||
'REGISTRY_USER_KEY', // This is the same as the prefilled account for LNT transfers
|
||||
'DEPLOYER_LRN',
|
||||
'NEXT_PUBLIC_RECIPIENT_ADDRESS',
|
||||
'NEXT_PUBLIC_COSMOS_API_URL'
|
||||
'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}`
|
||||
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();
|
||||
|
||||
// Generate a random salt (6 alphanumeric characters) to prevent name collisions
|
||||
const generateSalt = (): string => {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
return Array.from({ length: 6 }, () => chars.charAt(Math.floor(Math.random() * chars.length))).join('');
|
||||
};
|
||||
const salt = generateSalt();
|
||||
console.log(`Generated salt: ${salt}`);
|
||||
|
||||
// Create DNS name in format: app_name-shortcommithash-salt
|
||||
const dnsName = `${sanitizedAppName}-${shortHash}-${salt}`;
|
||||
console.log(`DNS name with salt: ${dnsName} (sanitized from: ${appName})`);
|
||||
|
||||
|
||||
// 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 and salt parts
|
||||
const suffixPart = `-${shortHash}-${salt}`;
|
||||
const maxAppNameLength = 63 - suffixPart.length;
|
||||
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + suffixPart;
|
||||
// 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 with salt: ${cleanDnsName}`);
|
||||
|
||||
|
||||
console.log(`Final DNS name: ${cleanDnsName}`);
|
||||
|
||||
// Set up Registry config
|
||||
const config = {
|
||||
chainId: process.env.REGISTRY_CHAIN_ID!,
|
||||
rpcEndpoint: process.env.REGISTRY_RPC_ENDPOINT!,
|
||||
gqlEndpoint: process.env.REGISTRY_GQL_ENDPOINT!,
|
||||
bondId: process.env.REGISTRY_BOND_ID!,
|
||||
authority: process.env.REGISTRY_AUTHORITY!,
|
||||
privateKey: process.env.REGISTRY_USER_KEY!,
|
||||
fee: {
|
||||
gas: process.env.REGISTRY_GAS || '900000',
|
||||
fees: process.env.REGISTRY_FEES || '900000alnt',
|
||||
gasPrice: '0.001alnt', // Hardcoded valid gas price string with denomination
|
||||
},
|
||||
};
|
||||
|
||||
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('0.001alnt');
|
||||
const gasPrice = GasPrice.fromString(config.fee.gasPrice + ALNT_DENOM);
|
||||
console.log('Using manual gas price:', gasPrice);
|
||||
|
||||
const registry = new Registry(
|
||||
config.gqlEndpoint,
|
||||
config.rpcEndpoint,
|
||||
{ chainId: config.chainId, gasPrice }
|
||||
);
|
||||
|
||||
// Create LRN for the application with commit hash and salt
|
||||
// We already have the salt from earlier, so we use it directly
|
||||
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}-${salt}`;
|
||||
|
||||
|
||||
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}-${salt}`, // Include commit hash and salt in the record name
|
||||
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'
|
||||
};
|
||||
|
||||
// Create fee for transaction directly
|
||||
const fee = {
|
||||
amount: [{ denom: 'alnt', amount: process.env.REGISTRY_FEES?.replace('alnt', '') || '900000' }],
|
||||
gas: process.env.REGISTRY_GAS || '900000',
|
||||
};
|
||||
|
||||
|
||||
console.log('Application record data:', applicationRecord);
|
||||
|
||||
|
||||
// Publish the application record
|
||||
let applicationRecordId;
|
||||
try {
|
||||
const appRecordResult = await registryTransactionWithRetry(() =>
|
||||
const appRecordResult = await registryTransactionWithRetry(() =>
|
||||
registry.setRecord(
|
||||
{
|
||||
privateKey: config.privateKey,
|
||||
@@ -405,13 +322,12 @@ export async function POST(request: NextRequest) {
|
||||
bondId: config.bondId,
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
) as { id?: string };
|
||||
|
||||
|
||||
applicationRecordId = appRecordResult.id;
|
||||
console.log('Application record published with ID:', applicationRecordId);
|
||||
|
||||
|
||||
if (!applicationRecordId) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
@@ -425,7 +341,7 @@ export async function POST(request: NextRequest) {
|
||||
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 {
|
||||
@@ -437,11 +353,10 @@ export async function POST(request: NextRequest) {
|
||||
lrn
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
);
|
||||
console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`);
|
||||
|
||||
|
||||
// Set the versioned LRN (with repository_ref)
|
||||
await registryTransactionWithRetry(() =>
|
||||
registry.setName(
|
||||
@@ -450,7 +365,6 @@ export async function POST(request: NextRequest) {
|
||||
lrn: `${lrn}@${fullHash}`
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
);
|
||||
console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`);
|
||||
@@ -461,36 +375,40 @@ export async function POST(request: NextRequest) {
|
||||
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}-${salt}`, // Update name to match application record
|
||||
application: lrn, // LRN already includes commit hash and salt
|
||||
name: `${appName}-${shortHash}`, // Update name to match application record
|
||||
application: lrn, // LRN already includes commit hash
|
||||
deployer: deployerLrn,
|
||||
dns: cleanDnsName,
|
||||
config: {
|
||||
env: {
|
||||
LACONIC_HOSTED_CONFIG_laconicd_chain_id: process.env.REGISTRY_CHAIN_ID || 'laconic-testnet-2'
|
||||
}
|
||||
// TODO: Add feature to set env config for app
|
||||
env: {}
|
||||
},
|
||||
meta: {
|
||||
note: `Added via ATOM-Deploy @ ${timestamp}`,
|
||||
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: txHash,
|
||||
payment: finalTxHash,
|
||||
};
|
||||
|
||||
|
||||
console.log('Deployment request data:', deploymentRequestData);
|
||||
|
||||
|
||||
// Publish the deployment request
|
||||
let deploymentRequestId;
|
||||
try {
|
||||
const deployRequestResult = await registryTransactionWithRetry(() =>
|
||||
const deployRequestResult = await registryTransactionWithRetry(() =>
|
||||
registry.setRecord(
|
||||
{
|
||||
privateKey: config.privateKey,
|
||||
@@ -498,13 +416,12 @@ export async function POST(request: NextRequest) {
|
||||
bondId: config.bondId,
|
||||
},
|
||||
config.privateKey,
|
||||
fee
|
||||
)
|
||||
) as { id?: string };
|
||||
|
||||
|
||||
deploymentRequestId = deployRequestResult.id;
|
||||
console.log('Deployment request published with ID:', deploymentRequestId);
|
||||
|
||||
|
||||
if (!deploymentRequestId) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
@@ -518,7 +435,7 @@ export async function POST(request: NextRequest) {
|
||||
message: err instanceof Error ? err.message : 'Unknown error publishing deployment request'
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
|
||||
// Return combined results
|
||||
return NextResponse.json({
|
||||
id: deploymentRequestId,
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import "./globals.css";
|
||||
import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper";
|
||||
|
||||
@@ -14,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Deploy Frontends using ATOM and Laconic",
|
||||
description: "Deploy URLs to Laconic Registry using ATOM payments",
|
||||
title: `Deploy Frontends using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} and Laconic`,
|
||||
description: `Deploy URLs to Laconic Registry using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payments`,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+97
-63
@@ -1,17 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
// Dynamically import Keplr component to avoid SSR issues with browser APIs
|
||||
const KeplrConnect = dynamic(() => import('@/components/KeplrConnect'), { ssr: false });
|
||||
import URLForm from '@/components/URLForm';
|
||||
// Dynamically import PaymentModal component to avoid SSR issues with browser APIs
|
||||
const PaymentModal = dynamic(() => import('@/components/PaymentModal'), { ssr: false });
|
||||
import StatusDisplay from '@/components/StatusDisplay';
|
||||
import { createApplicationDeploymentRequest } from '@/services/registry';
|
||||
import { useCallback, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import URLForm from '@/components/URLForm';
|
||||
import StatusDisplay from '@/components/StatusDisplay';
|
||||
import { createApplicationDeploymentRequest } from '@/services/registry';
|
||||
import { SolanaWalletState } from '@/types';
|
||||
|
||||
// Dynamically import components to avoid SSR issues with browser APIs
|
||||
const SolanaConnect = dynamic(() => import('@/components/SolanaConnect'), { ssr: false });
|
||||
const PaymentModal = dynamic(() => import('@/components/PaymentModal'), { ssr: false });
|
||||
|
||||
export default function Home() {
|
||||
const [walletAddress, setWalletAddress] = useState<string | null>(null);
|
||||
const [showWalletConnection, setShowWalletConnection] = useState(false);
|
||||
const [solanaWalletState, setSolanaWalletState] = useState<SolanaWalletState>({
|
||||
connected: false,
|
||||
publicKey: null,
|
||||
walletType: null
|
||||
});
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'creating' | 'success' | 'error'>('idle');
|
||||
@@ -26,8 +33,8 @@ export default function Home() {
|
||||
const [shortCommitHash, setShortCommitHash] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = (address: string) => {
|
||||
setWalletAddress(address);
|
||||
const handleConnectWallet = () => {
|
||||
setShowWalletConnection(true);
|
||||
};
|
||||
|
||||
const handleUrlSubmit = (submittedUrl: string) => {
|
||||
@@ -35,50 +42,52 @@ export default function Home() {
|
||||
setShowPaymentModal(true);
|
||||
};
|
||||
|
||||
const handlePaymentComplete = async (hash: string) => {
|
||||
const handlePaymentComplete = useCallback(async (hash: string) => {
|
||||
if (!solanaWalletState.publicKey || !url) {
|
||||
return
|
||||
}
|
||||
|
||||
setTxHash(hash);
|
||||
setShowPaymentModal(false);
|
||||
setStatus('creating');
|
||||
|
||||
|
||||
try {
|
||||
// Create the Laconic Registry record (payment verification is done in the API)
|
||||
if (url) {
|
||||
const result = await createApplicationDeploymentRequest(url, hash);
|
||||
|
||||
if (result.status === 'success') {
|
||||
setRecordId(result.id);
|
||||
if (result.applicationRecordId) {
|
||||
setAppRecordId(result.applicationRecordId);
|
||||
}
|
||||
if (result.lrn) {
|
||||
setLrn(result.lrn);
|
||||
}
|
||||
if (result.dns) {
|
||||
setDns(result.dns);
|
||||
}
|
||||
if (result.appName) {
|
||||
setAppName(result.appName);
|
||||
}
|
||||
if (result.repoUrl) {
|
||||
setRepoUrl(result.repoUrl);
|
||||
}
|
||||
if (result.commitHash) {
|
||||
setCommitHash(result.commitHash);
|
||||
}
|
||||
if (result.shortCommitHash) {
|
||||
setShortCommitHash(result.shortCommitHash);
|
||||
}
|
||||
setStatus('success');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setError(result.message || 'Failed to create record in Laconic Registry');
|
||||
const result = await createApplicationDeploymentRequest(url, hash);
|
||||
|
||||
if (result.status === 'success') {
|
||||
setRecordId(result.id);
|
||||
if (result.applicationRecordId) {
|
||||
setAppRecordId(result.applicationRecordId);
|
||||
}
|
||||
if (result.lrn) {
|
||||
setLrn(result.lrn);
|
||||
}
|
||||
if (result.dns) {
|
||||
setDns(result.dns);
|
||||
}
|
||||
if (result.appName) {
|
||||
setAppName(result.appName);
|
||||
}
|
||||
if (result.repoUrl) {
|
||||
setRepoUrl(result.repoUrl);
|
||||
}
|
||||
if (result.commitHash) {
|
||||
setCommitHash(result.commitHash);
|
||||
}
|
||||
if (result.shortCommitHash) {
|
||||
setShortCommitHash(result.shortCommitHash);
|
||||
}
|
||||
setStatus('success');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setError(result.message || 'Failed to create record in Laconic Registry');
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setError(error instanceof Error ? error.message : 'An unknown error occurred');
|
||||
}
|
||||
};
|
||||
}, [solanaWalletState, url])
|
||||
|
||||
const handleClosePaymentModal = () => {
|
||||
setShowPaymentModal(false);
|
||||
@@ -86,33 +95,57 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col items-center justify-center p-6">
|
||||
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }}
|
||||
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }}
|
||||
className="max-w-xl w-full p-8 rounded-xl shadow-lg border">
|
||||
<h1 className="text-2xl font-bold mb-8 text-center" style={{ color: 'var(--foreground)' }}>
|
||||
Deploy Frontends with ATOM and Laconic
|
||||
Deploy Frontends with {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} + Laconic
|
||||
</h1>
|
||||
|
||||
|
||||
<div className="mb-10 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<span className="inline-flex items-center justify-center mr-3 w-7 h-7 rounded-full text-sm font-bold"
|
||||
<span className="inline-flex items-center justify-center mr-3 w-7 h-7 rounded-full text-sm font-bold"
|
||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>1</span>
|
||||
Connect Your Wallet
|
||||
</h2>
|
||||
<KeplrConnect onConnect={handleConnect} />
|
||||
{!showWalletConnection ? (
|
||||
<div className="text-center">
|
||||
<p className="mb-4" style={{ color: 'var(--muted-foreground)' }}>
|
||||
Payment method: <span className="font-semibold" style={{ color: 'var(--foreground)' }}>
|
||||
{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} (Solana)
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handleConnectWallet}
|
||||
className="px-6 py-2 rounded-lg font-medium transition-colors"
|
||||
style={{
|
||||
background: 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
Connect Solana Wallet
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<SolanaConnect onConnect={(walletState) => setSolanaWalletState(walletState)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)', opacity: walletAddress ? '1' : '0.6' }}>
|
||||
<div className="mb-8 p-6 rounded-lg" style={{
|
||||
background: 'var(--muted-light)',
|
||||
borderLeft: '4px solid var(--primary)',
|
||||
opacity: solanaWalletState.connected ? '1' : '0.6'
|
||||
}}>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<span className="inline-flex items-center justify-center mr-3 w-7 h-7 rounded-full text-sm font-bold"
|
||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>2</span>
|
||||
Enter URL to Deploy
|
||||
</h2>
|
||||
<URLForm
|
||||
onSubmit={handleUrlSubmit}
|
||||
disabled={!walletAddress || status === 'creating'}
|
||||
<URLForm
|
||||
onSubmit={handleUrlSubmit}
|
||||
disabled={!solanaWalletState.connected || status === 'creating'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{status !== 'idle' && (
|
||||
<div className="p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
||||
@@ -120,10 +153,10 @@ export default function Home() {
|
||||
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>3</span>
|
||||
Deployment Status
|
||||
</h2>
|
||||
<StatusDisplay
|
||||
status={status}
|
||||
txHash={txHash || undefined}
|
||||
recordId={recordId || undefined}
|
||||
<StatusDisplay
|
||||
status={status}
|
||||
txHash={txHash || undefined}
|
||||
recordId={recordId || undefined}
|
||||
appRecordId={appRecordId || undefined}
|
||||
lrn={lrn || undefined}
|
||||
dns={dns || undefined}
|
||||
@@ -131,18 +164,19 @@ export default function Home() {
|
||||
repoUrl={repoUrl || undefined}
|
||||
commitHash={commitHash || undefined}
|
||||
shortCommitHash={shortCommitHash || undefined}
|
||||
error={error || undefined}
|
||||
error={error || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPaymentModal && walletAddress && url && (
|
||||
<PaymentModal
|
||||
|
||||
{showPaymentModal && url && solanaWalletState.connected && (
|
||||
<PaymentModal
|
||||
isOpen={showPaymentModal}
|
||||
onClose={handleClosePaymentModal}
|
||||
url={url}
|
||||
onPaymentComplete={handlePaymentComplete}
|
||||
walletState={solanaWalletState}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { connectKeplr } from '@/services/keplr';
|
||||
|
||||
interface KeplrConnectProps {
|
||||
onConnect: (address: string) => void;
|
||||
}
|
||||
|
||||
export default function KeplrConnect({ onConnect }: KeplrConnectProps) {
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setConnecting(true);
|
||||
try {
|
||||
const userAddress = await connectKeplr();
|
||||
if (userAddress) {
|
||||
setAddress(userAddress);
|
||||
onConnect(userAddress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Keplr:', error);
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Keplr is available
|
||||
if (typeof window !== 'undefined' && window.keplr) {
|
||||
// Auto-connect on page load
|
||||
handleConnect();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 rounded-lg">
|
||||
{address ? (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: 'var(--success)' }}></span>
|
||||
<p className="font-medium" style={{ color: 'var(--success)' }}>Connected</p>
|
||||
</div>
|
||||
<div className="w-full p-3 rounded-md" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
||||
<p className="text-sm font-mono break-all text-center">{address}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting}
|
||||
className="px-6 py-3 rounded-md w-full sm:w-auto transition-colors"
|
||||
style={{
|
||||
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
opacity: connecting ? '0.8' : '1',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{connecting && (
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{connecting ? 'Connecting...' : 'Connect Keplr Wallet'}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { sendAtomPayment } from '@/services/keplr';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import BN from 'bn.js';
|
||||
import assert from 'assert';
|
||||
|
||||
interface PaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
onPaymentComplete: (txHash: string) => void;
|
||||
}
|
||||
import { Connection } from '@solana/web3.js';
|
||||
|
||||
import { sendSolanaTokenPayment } from '@/services/solana';
|
||||
import { PaymentModalProps } from '@/types';
|
||||
|
||||
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
|
||||
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
|
||||
|
||||
export default function PaymentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
url,
|
||||
onPaymentComplete,
|
||||
walletState,
|
||||
}: PaymentModalProps) {
|
||||
const [amount, setAmount] = useState('0.01');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Get recipient address from environment variables
|
||||
const recipientAddress = process.env.NEXT_PUBLIC_RECIPIENT_ADDRESS || 'cosmos1yourrealaddress';
|
||||
|
||||
// Validate amount on change
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setAmount(value);
|
||||
|
||||
// Clear error when user types
|
||||
if (error) {
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
const connection = useMemo(() => new Connection(SOLANA_RPC_URL), [])
|
||||
|
||||
const handlePayment = async () => {
|
||||
// Validate amount before sending
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (isNaN(parsedAmount) || parsedAmount <= 0) {
|
||||
setError('Please enter a valid positive amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get configuration from environment variables directly
|
||||
const amount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!);
|
||||
const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!);
|
||||
|
||||
const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
|
||||
|
||||
const handlePayment = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
|
||||
try {
|
||||
const result = await sendAtomPayment(recipientAddress, amount);
|
||||
|
||||
if (result.status === 'success' && result.hash) {
|
||||
onPaymentComplete(result.hash);
|
||||
const fullAmount = amount * Math.pow(10, decimals);
|
||||
const tokenAmount = new BN(fullAmount);
|
||||
|
||||
const result = await sendSolanaTokenPayment(connection, walletState.publicKey!, tokenAmount, walletState.walletType!);
|
||||
|
||||
if (result.success && result.transactionSignature) {
|
||||
onPaymentComplete(result.transactionSignature);
|
||||
} else {
|
||||
setError(result.message || 'Payment failed. Please try again.');
|
||||
setError(result.error || `${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment failed. Please try again.`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Payment failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [connection, walletState, amount, decimals, onPaymentComplete]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4 z-50" style={{ background: 'rgba(15, 23, 42, 0.75)' }}>
|
||||
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
|
||||
style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
||||
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
|
||||
style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
||||
<div className="p-6 border-b" style={{ borderColor: 'var(--card-border)' }}>
|
||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>Complete Payment</h2>
|
||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>
|
||||
Complete {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Payment
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<p className="text-sm mb-2 font-medium" style={{ color: 'var(--muted)' }}>URL to be deployed:</p>
|
||||
@@ -77,51 +71,54 @@ export default function PaymentModal({
|
||||
<code className="text-sm font-mono">{url}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<p className="text-sm mb-2 font-medium" style={{ color: 'var(--muted)' }}>Recipient Address:</p>
|
||||
<div className="p-3 rounded-md overflow-hidden" style={{ background: 'var(--muted-light)', color: 'var(--foreground)' }}>
|
||||
<code className="text-sm font-mono break-all block">{recipientAddress}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label htmlFor="amount" className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
|
||||
Amount (ATOM)
|
||||
Amount ({process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL})
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={handleAmountChange}
|
||||
disabled={true} // Fixed amount for Solana tokens
|
||||
className="w-full p-3 pr-12 rounded-md"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--input-border)',
|
||||
color: 'var(--foreground)'
|
||||
color: 'var(--foreground)',
|
||||
opacity: '0.7'
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>ATOM</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
|
||||
Fixed amount required for deployment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: 'var(--error-light)', color: 'var(--error)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 flex justify-end space-x-4 border-t" style={{ borderColor: 'var(--card-border)' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-md transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
border: '1px solid var(--input-border)',
|
||||
color: 'var(--foreground)',
|
||||
opacity: loading ? '0.5' : '1'
|
||||
@@ -146,7 +143,7 @@ export default function PaymentModal({
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{loading ? 'Processing...' : 'Pay with Keplr'}
|
||||
{loading ? 'Processing...' : 'Pay with Solana Wallet'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { connectSolanaWallet, disconnectSolanaWallet, checkSolanaWalletConnection } from '@/services/solana';
|
||||
import { SolanaWalletType, SolanaWalletState } from '@/types';
|
||||
|
||||
interface SolanaConnectProps {
|
||||
onConnect: (walletState: SolanaWalletState) => void;
|
||||
}
|
||||
|
||||
export default function SolanaConnect({ onConnect }: SolanaConnectProps) {
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [walletState, setWalletState] = useState<SolanaWalletState>({
|
||||
connected: false,
|
||||
publicKey: null,
|
||||
walletType: null
|
||||
});
|
||||
|
||||
const handleConnect = async (walletType: SolanaWalletType) => {
|
||||
setConnecting(true);
|
||||
try {
|
||||
const newWalletState = await connectSolanaWallet(walletType);
|
||||
setWalletState(newWalletState);
|
||||
onConnect(newWalletState);
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Solana wallet:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to connect wallet');
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (walletState.walletType) {
|
||||
try {
|
||||
await disconnectSolanaWallet(walletState.walletType);
|
||||
const disconnectedState = {
|
||||
connected: false,
|
||||
publicKey: null,
|
||||
walletType: null
|
||||
};
|
||||
setWalletState(disconnectedState);
|
||||
onConnect(disconnectedState);
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect wallet:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check for auto-connection on page load
|
||||
const checkConnection = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Check Phantom
|
||||
if (window.phantom?.solana && checkSolanaWalletConnection('phantom')) {
|
||||
handleConnect('phantom');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Solflare
|
||||
if (window.solflare && checkSolanaWalletConnection('solflare')) {
|
||||
handleConnect('solflare');
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 rounded-lg">
|
||||
{walletState.connected ? (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: 'var(--success)' }}></span>
|
||||
<p className="font-medium" style={{ color: 'var(--success)' }}>
|
||||
Connected ({walletState.walletType})
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-3 rounded-md mb-3" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
|
||||
<p className="text-sm font-mono break-all text-center">{walletState.publicKey}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="px-4 py-2 rounded-md text-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--muted)',
|
||||
color: 'var(--foreground)',
|
||||
border: '1px solid var(--input-border)'
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-3">
|
||||
<button
|
||||
onClick={() => handleConnect('phantom')}
|
||||
disabled={connecting || !window.phantom?.solana}
|
||||
className="px-6 py-3 rounded-md w-full transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{connecting && (
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{connecting ? 'Connecting...' : 'Connect Phantom Wallet'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleConnect('solflare')}
|
||||
disabled={connecting || !window.solflare}
|
||||
className="px-6 py-3 rounded-md w-full transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{connecting && (
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{connecting ? 'Connecting...' : 'Connect Solflare Wallet'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!window.phantom?.solana && !window.solflare && (
|
||||
<p className="text-sm text-center" style={{ color: 'var(--muted)' }}>
|
||||
Please install Phantom or Solflare wallet extension
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export default function StatusDisplay({
|
||||
error,
|
||||
}: StatusDisplayProps) {
|
||||
// Get domain suffix from environment variable
|
||||
const domainSuffix = process.env.NEXT_PUBLIC_DOMAIN_SUFFIX || '';
|
||||
const domainSuffix = process.env.NEXT_PUBLIC_DOMAIN_SUFFIX;
|
||||
if (status === 'idle') return null;
|
||||
|
||||
const StatusBadge = ({ type }: { type: 'creating' | 'success' | 'error' }) => {
|
||||
@@ -58,7 +58,7 @@ export default function StatusDisplay({
|
||||
const styles = getBadgeStyles();
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold"
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold"
|
||||
style={{ backgroundColor: styles.bg, color: styles.color }}>
|
||||
{styles.text}
|
||||
</span>
|
||||
@@ -108,7 +108,7 @@ export default function StatusDisplay({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="mt-4">
|
||||
{appName && (
|
||||
@@ -124,15 +124,19 @@ export default function StatusDisplay({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{txHash && <InfoItem label="ATOM Payment Transaction Hash" value={txHash} />}
|
||||
|
||||
{txHash && <InfoItem label={`${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Payment Transaction Hash`} value={txHash} />}
|
||||
{appRecordId && <InfoItem label="Laconic Application Record ID" value={appRecordId} />}
|
||||
{recordId && <InfoItem label="Laconic Deployment Request Record ID" value={recordId} />}
|
||||
{lrn && <InfoItem label="Laconic Resource Name (LRN)" value={lrn} />}
|
||||
{dns && <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={domainSuffix ? `https://${dns}${domainSuffix}` : `http://${dns}`} />}
|
||||
{dns && (
|
||||
domainSuffix
|
||||
? <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={`https://${dns}.${domainSuffix}`} />
|
||||
: <InfoItem label="Please set domain suffix" value="" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="mt-4">
|
||||
<div className="p-3 rounded-md mb-4" style={{ backgroundColor: 'var(--error-light)', color: 'var(--error)' }}>
|
||||
|
||||
+14
-13
@@ -10,46 +10,47 @@ interface URLFormProps {
|
||||
export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
||||
// Get example URL from environment variables or use a default
|
||||
const exampleUrl = process.env.NEXT_PUBLIC_EXAMPLE_URL || 'https://example.com';
|
||||
const [url, setUrl] = useState(exampleUrl);
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
// Trim the URL to remove any whitespace
|
||||
const trimmedUrl = url.trim();
|
||||
|
||||
|
||||
if (!trimmedUrl) {
|
||||
setError('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const parsedUrl = new URL(trimmedUrl);
|
||||
|
||||
|
||||
// Check for protocol
|
||||
if (!parsedUrl.protocol.startsWith('http')) {
|
||||
setError('URL must use HTTP or HTTPS protocol');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check for hostname
|
||||
if (!parsedUrl.hostname || parsedUrl.hostname.length < 3) {
|
||||
setError('URL must contain a valid hostname');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Basic sanity check for common invalid URLs
|
||||
if (parsedUrl.href === 'http://localhost' || parsedUrl.href === 'https://localhost') {
|
||||
setError('Please enter a valid public URL, not localhost');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// All validations passed
|
||||
setError('');
|
||||
onSubmit(trimmedUrl);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError('Please enter a valid URL (e.g., https://example.com)');
|
||||
}
|
||||
};
|
||||
@@ -68,7 +69,7 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={exampleUrl}
|
||||
className="w-full p-3 rounded-md transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--input-border)',
|
||||
color: 'var(--foreground)',
|
||||
@@ -91,12 +92,12 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !url}
|
||||
className="w-full px-6 py-3 rounded-md transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor: (disabled || !url) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
opacity: (disabled || !url) ? '0.7' : '1',
|
||||
@@ -106,4 +107,4 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-16
@@ -1,5 +1,24 @@
|
||||
import { Registry, DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
|
||||
import { GasPrice } from '@cosmjs/stargate';
|
||||
|
||||
import { RegistryConfig } from '../types';
|
||||
|
||||
let registryInstance: Registry | null = null;
|
||||
|
||||
export const getRegistry = (): Registry => {
|
||||
if (!registryInstance) {
|
||||
const config = getRegistryConfig();
|
||||
const gasPrice = GasPrice.fromString(config.fee.gasPrice + ALNT_DENOM);
|
||||
|
||||
registryInstance = new Registry(
|
||||
config.gqlEndpoint,
|
||||
config.rpcEndpoint,
|
||||
{ chainId: config.chainId, gasPrice }
|
||||
);
|
||||
}
|
||||
return registryInstance;
|
||||
};
|
||||
|
||||
export const getRegistryConfig = (): RegistryConfig => {
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = [
|
||||
@@ -25,22 +44,7 @@ export const getRegistryConfig = (): RegistryConfig => {
|
||||
authority: process.env.REGISTRY_AUTHORITY!,
|
||||
privateKey: process.env.REGISTRY_USER_KEY!,
|
||||
fee: {
|
||||
gas: process.env.REGISTRY_GAS || '900000',
|
||||
fees: process.env.REGISTRY_FEES || '900000alnt',
|
||||
gasPrice: process.env.REGISTRY_GAS_PRICE || '0.025',
|
||||
gasPrice: process.env.REGISTRY_GAS_PRICE || '0.001',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getDeployerLrn = (): string => {
|
||||
if (!process.env.DEPLOYER_LRN) {
|
||||
throw new Error('Missing environment variable: DEPLOYER_LRN');
|
||||
}
|
||||
return process.env.DEPLOYER_LRN;
|
||||
};
|
||||
|
||||
export const getAppName = (): string => {
|
||||
return process.env.APP_NAME || 'atom-deploy';
|
||||
};
|
||||
|
||||
export const COSMOS_DENOM = 'uatom';
|
||||
@@ -1,260 +0,0 @@
|
||||
import { SigningStargateClient } from '@cosmjs/stargate';
|
||||
import { TransactionResponse } from '../types';
|
||||
import { COSMOS_DENOM } from '../config';
|
||||
|
||||
export const connectKeplr = async (): Promise<string | null> => {
|
||||
if (!window.keplr) {
|
||||
alert('Keplr wallet extension is not installed!');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const chainId = process.env.NEXT_PUBLIC_COSMOS_CHAIN_ID || 'cosmoshub-4';
|
||||
|
||||
// Try to suggest chain if custom network
|
||||
if (chainId !== 'cosmoshub-4') {
|
||||
try {
|
||||
// Check if we need to suggest the chain to Keplr
|
||||
await window.keplr.getKey(chainId).catch(async () => {
|
||||
// Chain needs to be suggested
|
||||
if (process.env.NEXT_PUBLIC_COSMOS_RPC_URL) {
|
||||
await window.keplr.experimentalSuggestChain({
|
||||
chainId: chainId,
|
||||
chainName: chainId,
|
||||
rpc: process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
||||
rest: process.env.NEXT_PUBLIC_COSMOS_REST_URL || process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
||||
bip44: {
|
||||
coinType: 118,
|
||||
},
|
||||
bech32Config: {
|
||||
bech32PrefixAccAddr: "cosmos",
|
||||
bech32PrefixAccPub: "cosmospub",
|
||||
bech32PrefixValAddr: "cosmosvaloper",
|
||||
bech32PrefixValPub: "cosmosvaloperpub",
|
||||
bech32PrefixConsAddr: "cosmosvalcons",
|
||||
bech32PrefixConsPub: "cosmosvalconspub",
|
||||
},
|
||||
currencies: [
|
||||
{
|
||||
coinDenom: "ATOM",
|
||||
coinMinimalDenom: "uatom",
|
||||
coinDecimals: 6,
|
||||
},
|
||||
],
|
||||
feeCurrencies: [
|
||||
{
|
||||
coinDenom: "ATOM",
|
||||
coinMinimalDenom: "uatom",
|
||||
coinDecimals: 6,
|
||||
},
|
||||
],
|
||||
stakeCurrency: {
|
||||
coinDenom: "ATOM",
|
||||
coinMinimalDenom: "uatom",
|
||||
coinDecimals: 6,
|
||||
},
|
||||
gasPriceStep: {
|
||||
low: 0.01,
|
||||
average: 0.025,
|
||||
high: 0.04,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (suggestError) {
|
||||
console.warn("Failed to suggest chain to Keplr:", suggestError);
|
||||
// Continue anyway, as enable might still work
|
||||
}
|
||||
}
|
||||
|
||||
// Enable Keplr for the specified chain
|
||||
await window.keplr.enable(chainId);
|
||||
const offlineSigner = window.keplr.getOfflineSigner(chainId);
|
||||
|
||||
// Get the user's account
|
||||
const accounts = await offlineSigner.getAccounts();
|
||||
if (!accounts || accounts.length === 0) {
|
||||
console.error('No accounts found in Keplr wallet');
|
||||
return null;
|
||||
}
|
||||
return accounts[0].address;
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Keplr wallet:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendAtomPayment = async (
|
||||
recipientAddress: string,
|
||||
amount: string
|
||||
): Promise<TransactionResponse> => {
|
||||
try {
|
||||
if (!window.keplr) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Keplr wallet extension is not installed!'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate recipient address is a valid cosmos address
|
||||
if (!recipientAddress || !recipientAddress.startsWith('cosmos1')) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Invalid recipient address. Must be a valid Cosmos address starting with cosmos1'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate amount is a positive number
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (isNaN(parsedAmount) || parsedAmount <= 0) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Invalid amount. Must be a positive number'
|
||||
};
|
||||
}
|
||||
|
||||
// Get the chain ID from environment variables or use default
|
||||
const chainId = process.env.NEXT_PUBLIC_COSMOS_CHAIN_ID || 'cosmoshub-4';
|
||||
|
||||
// Enable the chain in Keplr, following same logic as connectKeplr
|
||||
if (chainId !== 'cosmoshub-4') {
|
||||
try {
|
||||
// Check if we need to suggest the chain to Keplr
|
||||
await window.keplr.getKey(chainId).catch(async () => {
|
||||
// Chain needs to be suggested
|
||||
if (process.env.NEXT_PUBLIC_COSMOS_RPC_URL) {
|
||||
await window.keplr.experimentalSuggestChain({
|
||||
chainId: chainId,
|
||||
chainName: chainId,
|
||||
rpc: process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
||||
rest: process.env.NEXT_PUBLIC_COSMOS_REST_URL || process.env.NEXT_PUBLIC_COSMOS_RPC_URL,
|
||||
bip44: { coinType: 118 },
|
||||
bech32Config: {
|
||||
bech32PrefixAccAddr: "cosmos",
|
||||
bech32PrefixAccPub: "cosmospub",
|
||||
bech32PrefixValAddr: "cosmosvaloper",
|
||||
bech32PrefixValPub: "cosmosvaloperpub",
|
||||
bech32PrefixConsAddr: "cosmosvalcons",
|
||||
bech32PrefixConsPub: "cosmosvalconspub",
|
||||
},
|
||||
currencies: [{ coinDenom: "ATOM", coinMinimalDenom: "uatom", coinDecimals: 6 }],
|
||||
feeCurrencies: [{ coinDenom: "ATOM", coinMinimalDenom: "uatom", coinDecimals: 6 }],
|
||||
stakeCurrency: { coinDenom: "ATOM", coinMinimalDenom: "uatom", coinDecimals: 6 },
|
||||
gasPriceStep: { low: 0.01, average: 0.025, high: 0.04 },
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (suggestError) {
|
||||
console.warn("Failed to suggest chain to Keplr:", suggestError);
|
||||
// Continue anyway, as enable might still work
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the chain in Keplr
|
||||
await window.keplr.enable(chainId);
|
||||
const offlineSigner = window.keplr.getOfflineSigner(chainId);
|
||||
|
||||
// Create the Stargate client
|
||||
const rpcEndpoint = process.env.NEXT_PUBLIC_COSMOS_RPC_URL;
|
||||
if (!rpcEndpoint) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'NEXT_PUBLIC_COSMOS_RPC_URL environment variable is not set'
|
||||
};
|
||||
}
|
||||
|
||||
const client = await SigningStargateClient.connectWithSigner(
|
||||
rpcEndpoint,
|
||||
offlineSigner
|
||||
);
|
||||
|
||||
// Get the user's account
|
||||
const accounts = await offlineSigner.getAccounts();
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'No accounts found in Keplr wallet'
|
||||
};
|
||||
}
|
||||
const sender = accounts[0].address;
|
||||
|
||||
// Convert amount to microdenom (e.g., ATOM to uatom)
|
||||
const microAmount = convertToMicroDenom(amount);
|
||||
|
||||
// Send the transaction
|
||||
const result = await client.sendTokens(
|
||||
sender,
|
||||
recipientAddress,
|
||||
[{ denom: COSMOS_DENOM, amount: microAmount }],
|
||||
{
|
||||
amount: [{ denom: COSMOS_DENOM, amount: '5000' }],
|
||||
gas: '200000',
|
||||
}
|
||||
);
|
||||
|
||||
if (!result || !result.transactionHash) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Transaction did not return a valid hash'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hash: result.transactionHash,
|
||||
status: 'success',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to send ATOM payment:', error);
|
||||
// Provide more descriptive error messages for common errors
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
|
||||
if (errorMessage.includes('insufficient funds')) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Insufficient funds in your Keplr wallet to complete this transaction'
|
||||
};
|
||||
} else if (errorMessage.includes('rejected')) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Transaction was rejected in the Keplr wallet'
|
||||
};
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Transaction timed out. Please try again'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hash: '',
|
||||
status: 'error',
|
||||
message: 'Unknown error occurred while sending payment'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to convert from ATOM to uatom (1 ATOM = 1,000,000 uatom)
|
||||
export const convertToMicroDenom = (amount: string): string => {
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (isNaN(parsedAmount)) {
|
||||
throw new Error('Invalid amount');
|
||||
}
|
||||
return Math.floor(parsedAmount * 1_000_000).toString();
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Account, DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
|
||||
import { DeliverTxResponse } from '@cosmjs/stargate';
|
||||
|
||||
import { registryTransactionWithRetry } from '@/app/api/registry/route';
|
||||
import { getRegistry, getRegistryConfig } from '../config';
|
||||
import { LaconicTransferResult } from '../types';
|
||||
|
||||
export const transferLNTTokens = async (): Promise<LaconicTransferResult> => {
|
||||
try {
|
||||
const registryConfig = getRegistryConfig();
|
||||
const registry = getRegistry();
|
||||
|
||||
console.log('Resolving deployer LRN to get payment address...');
|
||||
|
||||
// Resolve the deployer LRN to get the payment address
|
||||
const deployerLrn = process.env.DEPLOYER_LRN;
|
||||
if (!deployerLrn) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'DEPLOYER_LRN environment variable is required'
|
||||
};
|
||||
}
|
||||
|
||||
const resolveResult = await registry.resolveNames([deployerLrn]);
|
||||
console.log('Resolve result:', resolveResult);
|
||||
|
||||
if (!resolveResult || resolveResult.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to resolve deployer LRN: ${deployerLrn}`
|
||||
};
|
||||
}
|
||||
|
||||
const deployerRecord = resolveResult[0];
|
||||
if (!deployerRecord.attributes) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Deployer record has no attributes'
|
||||
};
|
||||
}
|
||||
|
||||
// Find the paymentAddress attribute
|
||||
const paymentAddress = deployerRecord.attributes.paymentAddress
|
||||
const deployerMinPayment = (deployerRecord.attributes.minimumPayment as string).split(ALNT_DENOM)[0]
|
||||
|
||||
console.log('Found payment address:', paymentAddress);
|
||||
console.log('Found minimum payment:', deployerMinPayment);
|
||||
|
||||
console.log('Initiating LNT transfer...');
|
||||
|
||||
// Send tokens from prefilled account to payment address
|
||||
const transferResult = await sendTokensToAccount(
|
||||
registryConfig.privateKey,
|
||||
paymentAddress,
|
||||
deployerMinPayment
|
||||
);
|
||||
|
||||
console.log('LNT transfer result:', transferResult);
|
||||
|
||||
if (!transferResult.transactionHash) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'LNT transfer failed - no transaction hash returned'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transactionHash: transferResult.transactionHash
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to transfer LNT tokens:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during LNT transfer'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getAccount = async (accountPrivateKey: string): Promise<Account> => {
|
||||
const account = new Account(
|
||||
Buffer.from(accountPrivateKey, 'hex'),
|
||||
);
|
||||
await account.init();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
|
||||
const sendTokensToAccount = async (
|
||||
senderPrivateKey: string,
|
||||
receiverAddress: string,
|
||||
amount: string,
|
||||
): Promise<DeliverTxResponse> => {
|
||||
const registry = getRegistry();
|
||||
|
||||
const account = await getAccount(senderPrivateKey);
|
||||
const laconicClient = await registry.getLaconicClient(account);
|
||||
|
||||
const txResponse: DeliverTxResponse = await registryTransactionWithRetry(
|
||||
() =>
|
||||
laconicClient.sendTokens(
|
||||
account.address,
|
||||
receiverAddress,
|
||||
[
|
||||
{
|
||||
denom: ALNT_DENOM,
|
||||
amount,
|
||||
},
|
||||
],
|
||||
"auto",
|
||||
),
|
||||
);
|
||||
|
||||
return txResponse;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { CreateRecordResponse } from '../types';
|
||||
|
||||
export const createApplicationDeploymentRequest = async (
|
||||
url: string,
|
||||
txHash: string
|
||||
txHash: string,
|
||||
): Promise<CreateRecordResponse> => {
|
||||
try {
|
||||
console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash}`);
|
||||
|
||||
console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash} using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment`);
|
||||
|
||||
// Call our serverless API endpoint to handle the registry interaction
|
||||
const response = await fetch('/api/registry', {
|
||||
method: 'POST',
|
||||
@@ -16,10 +15,10 @@ export const createApplicationDeploymentRequest = async (
|
||||
},
|
||||
body: JSON.stringify({ url, txHash }),
|
||||
});
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
console.log('API response:', result);
|
||||
|
||||
|
||||
if (response.ok && result.status === 'success') {
|
||||
return {
|
||||
id: result.id,
|
||||
@@ -50,63 +49,3 @@ export const createApplicationDeploymentRequest = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyTransaction = async (txHash: string): Promise<boolean> => {
|
||||
try {
|
||||
// Use the public Cosmos API URL for verification queries
|
||||
const apiEndpoint = process.env.NEXT_PUBLIC_COSMOS_API_URL;
|
||||
if (!apiEndpoint) {
|
||||
console.error('NEXT_PUBLIC_COSMOS_API_URL environment variable not set');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use Axios to directly query the Cosmos transaction via REST API
|
||||
const response = await axios.get(`${apiEndpoint}/cosmos/tx/v1beta1/txs/${txHash}`);
|
||||
|
||||
// Check if transaction exists and was successful
|
||||
// The Cosmos API returns a tx_response object with a code field - 0 means success
|
||||
if (response.data &&
|
||||
response.data.tx_response &&
|
||||
response.data.tx_response.code === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check for successful transactions with code === undefined (some nodes report it this way)
|
||||
if (response.data &&
|
||||
response.data.tx_response &&
|
||||
response.data.tx_response.code === undefined &&
|
||||
response.data.tx_response.height) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also fallback to checking if the transaction has a height (was included in a block)
|
||||
if (response.data &&
|
||||
response.data.tx_response &&
|
||||
response.data.tx_response.height &&
|
||||
!response.data.tx_response.code) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to verify transaction:', error);
|
||||
|
||||
// If the API call fails, try checking a public explorer API as fallback
|
||||
try {
|
||||
// Try a different URL format that some RPC nodes might use
|
||||
const rpcEndpoint = process.env.NEXT_PUBLIC_COSMOS_RPC_URL;
|
||||
const fallbackResponse = await axios.get(`${rpcEndpoint}/tx?hash=0x${txHash}`);
|
||||
|
||||
if (fallbackResponse.data &&
|
||||
fallbackResponse.data.result &&
|
||||
(fallbackResponse.data.result.height ||
|
||||
(fallbackResponse.data.result.tx_result &&
|
||||
fallbackResponse.data.result.tx_result.code === 0))) {
|
||||
return true;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback verification also failed:', fallbackError);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import assert from 'assert';
|
||||
import BN from 'bn.js';
|
||||
import { Connection, PublicKey, Transaction } from '@solana/web3.js';
|
||||
import {
|
||||
TOKEN_PROGRAM_ID,
|
||||
createTransferInstruction,
|
||||
createAssociatedTokenAccountInstruction,
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
} from '@solana/spl-token';
|
||||
|
||||
import { SolanaPaymentResult, SolanaWalletType, SolanaWalletState } from '../types';
|
||||
|
||||
assert(process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS, 'SOLANA_TOKEN_MINT_ADDRESS is required');
|
||||
assert(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS, 'SOLANA_TOKEN_RECIPIENT_ADDRESS is required');
|
||||
|
||||
const TOKEN_MINT = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS;
|
||||
const PAYMENT_RECEIVER_ADDRESS = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
|
||||
|
||||
export const connectSolanaWallet = async (walletType: SolanaWalletType): Promise<SolanaWalletState> => {
|
||||
try {
|
||||
if (walletType === 'phantom') {
|
||||
if (!window.phantom?.solana) {
|
||||
throw new Error('Phantom wallet not found. Please install Phantom browser extension.');
|
||||
}
|
||||
const response = await window.phantom.solana.connect();
|
||||
return {
|
||||
connected: true,
|
||||
publicKey: response.publicKey.toString(),
|
||||
walletType
|
||||
};
|
||||
} else if (walletType === 'solflare') {
|
||||
if (!window.solflare) {
|
||||
throw new Error('Solflare wallet not found. Please install Solflare browser extension.');
|
||||
}
|
||||
await window.solflare.connect();
|
||||
const publicKey = window.solflare.publicKey?.toString();
|
||||
if (!publicKey) {
|
||||
throw new Error('Failed to get public key from Solflare wallet');
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
publicKey,
|
||||
walletType
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported wallet type: ${walletType}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Solana wallet:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const disconnectSolanaWallet = async (walletType: SolanaWalletType): Promise<void> => {
|
||||
try {
|
||||
let wallet = null;
|
||||
|
||||
if (walletType === 'phantom') {
|
||||
wallet = window.phantom?.solana;
|
||||
} else if (walletType === 'solflare') {
|
||||
wallet = window.solflare;
|
||||
}
|
||||
|
||||
if (wallet && wallet.disconnect) {
|
||||
await wallet.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect Solana wallet:', error);
|
||||
}
|
||||
};
|
||||
|
||||
async function findAssociatedTokenAddress(
|
||||
walletAddress: PublicKey,
|
||||
tokenMintAddress: PublicKey
|
||||
): Promise<PublicKey> {
|
||||
return PublicKey.findProgramAddressSync(
|
||||
[
|
||||
walletAddress.toBuffer(),
|
||||
TOKEN_PROGRAM_ID.toBuffer(),
|
||||
tokenMintAddress.toBuffer(),
|
||||
],
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
)[0];
|
||||
}
|
||||
|
||||
interface WalletAdapter {
|
||||
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
|
||||
}
|
||||
|
||||
export async function sendSolanaTokenPayment(
|
||||
connection: Connection,
|
||||
walletPublicKey: string,
|
||||
tokenAmount: BN,
|
||||
walletType: SolanaWalletType
|
||||
): Promise<SolanaPaymentResult> {
|
||||
try {
|
||||
let wallet: WalletAdapter | null = null;
|
||||
|
||||
if (walletType === 'phantom') {
|
||||
wallet = window.phantom?.solana || null;
|
||||
} else if (walletType === 'solflare') {
|
||||
wallet = window.solflare || null;
|
||||
}
|
||||
|
||||
if (!wallet) {
|
||||
throw new Error(`${walletType} wallet not found`);
|
||||
}
|
||||
|
||||
const senderPublicKey = new PublicKey(walletPublicKey);
|
||||
const mintPublicKey = new PublicKey(TOKEN_MINT);
|
||||
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
|
||||
|
||||
console.log('Processing payment with keys:', {
|
||||
sender: senderPublicKey.toBase58(),
|
||||
mint: mintPublicKey.toBase58(),
|
||||
receiver: receiverPublicKey.toBase58(),
|
||||
});
|
||||
|
||||
const senderATA = await findAssociatedTokenAddress(
|
||||
senderPublicKey,
|
||||
mintPublicKey
|
||||
);
|
||||
|
||||
const receiverATA = await findAssociatedTokenAddress(
|
||||
receiverPublicKey,
|
||||
mintPublicKey
|
||||
);
|
||||
|
||||
console.log('Token accounts:', {
|
||||
senderATA: senderATA.toBase58(),
|
||||
receiverATA: receiverATA.toBase58(),
|
||||
});
|
||||
|
||||
const transaction = new Transaction();
|
||||
|
||||
// Check if accounts exist
|
||||
const [senderATAInfo, receiverATAInfo] = await Promise.all([
|
||||
connection.getAccountInfo(senderATA),
|
||||
connection.getAccountInfo(receiverATA),
|
||||
]);
|
||||
|
||||
// Create receiver token account if it doesn't exist
|
||||
if (!receiverATAInfo) {
|
||||
console.log('Creating receiver token account');
|
||||
transaction.add(
|
||||
createAssociatedTokenAccountInstruction(
|
||||
senderPublicKey,
|
||||
receiverATA,
|
||||
receiverPublicKey,
|
||||
mintPublicKey
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Create sender token account if it doesn't exist
|
||||
if (!senderATAInfo) {
|
||||
console.log('Creating sender token account');
|
||||
transaction.add(
|
||||
createAssociatedTokenAccountInstruction(
|
||||
senderPublicKey,
|
||||
senderATA,
|
||||
senderPublicKey,
|
||||
mintPublicKey
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const amountToSend = BigInt(tokenAmount.toString());
|
||||
|
||||
// Add transfer instruction
|
||||
transaction.add(
|
||||
createTransferInstruction(
|
||||
senderATA,
|
||||
receiverATA,
|
||||
senderPublicKey,
|
||||
amountToSend
|
||||
)
|
||||
);
|
||||
|
||||
// Set transaction details
|
||||
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
|
||||
transaction.recentBlockhash = latestBlockhash.blockhash;
|
||||
transaction.feePayer = senderPublicKey;
|
||||
|
||||
console.log('Sending transaction...');
|
||||
const { signature } = await wallet.signAndSendTransaction(transaction);
|
||||
console.log('Transaction sent:', signature);
|
||||
|
||||
// Confirm transaction
|
||||
const confirmation = await connection.confirmTransaction({
|
||||
signature,
|
||||
blockhash: latestBlockhash.blockhash,
|
||||
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
|
||||
}, 'confirmed');
|
||||
|
||||
if (confirmation.value.err) {
|
||||
console.error('Transaction error:', confirmation.value.err);
|
||||
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transactionSignature: signature
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Payment failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check wallet connection status
|
||||
export const checkSolanaWalletConnection = (walletType: SolanaWalletType): boolean => {
|
||||
try {
|
||||
if (walletType === 'phantom') {
|
||||
return window.phantom?.solana?.isConnected || false;
|
||||
} else if (walletType === 'solflare') {
|
||||
return window.solflare?.isConnected || false;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
+47
-27
@@ -1,8 +1,23 @@
|
||||
import { Window as KeplrWindow } from "@keplr-wallet/types";
|
||||
import { Transaction } from "@solana/web3.js";
|
||||
|
||||
// extend the global Window interface to include Keplr
|
||||
// extend the global Window interface to include Solana wallets
|
||||
declare global {
|
||||
interface Window extends KeplrWindow {}
|
||||
interface Window {
|
||||
phantom?: {
|
||||
solana?: {
|
||||
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
|
||||
connect(): Promise<{ publicKey: { toString(): string } }>;
|
||||
disconnect(): Promise<void>;
|
||||
isConnected: boolean;
|
||||
};
|
||||
};
|
||||
solflare?: {
|
||||
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
|
||||
connect(): Promise<{ publicKey: { toString(): string } }>;
|
||||
disconnect(): Promise<void>;
|
||||
isConnected: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegistryConfig {
|
||||
@@ -13,33 +28,10 @@ export interface RegistryConfig {
|
||||
authority: string;
|
||||
privateKey: string;
|
||||
fee: {
|
||||
gas: string;
|
||||
fees: string;
|
||||
gasPrice: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransactionResponse {
|
||||
hash: string;
|
||||
status: 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface LaconicRecordData {
|
||||
type: string;
|
||||
version: string;
|
||||
name: string;
|
||||
application: string;
|
||||
deployer: string;
|
||||
dns: string;
|
||||
meta: {
|
||||
note: string;
|
||||
repository: string;
|
||||
repository_ref: string;
|
||||
};
|
||||
payment: string;
|
||||
}
|
||||
|
||||
export interface CreateRecordResponse {
|
||||
id: string;
|
||||
applicationRecordId?: string;
|
||||
@@ -51,4 +43,32 @@ export interface CreateRecordResponse {
|
||||
shortCommitHash?: string;
|
||||
status: 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export type SolanaWalletType = 'phantom' | 'solflare';
|
||||
|
||||
export interface SolanaPaymentResult {
|
||||
success: boolean;
|
||||
transactionSignature?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
onPaymentComplete: (txHash: string) => void;
|
||||
walletState: SolanaWalletState;
|
||||
}
|
||||
|
||||
export interface SolanaWalletState {
|
||||
connected: boolean;
|
||||
publicKey: string | null;
|
||||
walletType: SolanaWalletType | null;
|
||||
}
|
||||
|
||||
export interface LaconicTransferResult {
|
||||
success: boolean;
|
||||
transactionHash?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import BN from 'bn.js';
|
||||
|
||||
import { Connection, PublicKey } from '@solana/web3.js';
|
||||
import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token';
|
||||
|
||||
// Simplified transaction info extraction following reference implementation
|
||||
const extractTxInfo = async (connection: Connection, transactionSignature: string): Promise<{ authority: string; amount: string }> => {
|
||||
const result = await connection.getParsedTransaction(transactionSignature, 'confirmed');
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
const transferInstruction = result.transaction.message.instructions.find(
|
||||
(instr) => 'parsed' in instr && instr.programId.equals(TOKEN_PROGRAM_ID)
|
||||
);
|
||||
|
||||
if (!transferInstruction || !('parsed' in transferInstruction)) {
|
||||
throw new Error('Transfer instruction not found');
|
||||
}
|
||||
|
||||
const { info: { amount, authority } } = transferInstruction.parsed;
|
||||
return { authority, amount };
|
||||
};
|
||||
|
||||
export const verifyUnusedSolanaPayment = async (
|
||||
connection: Connection,
|
||||
transactionSignature: string,
|
||||
tokenAmount: BN
|
||||
): Promise<{
|
||||
valid: boolean,
|
||||
reason?: string,
|
||||
amount?: string,
|
||||
sender?: string
|
||||
}> => {
|
||||
try {
|
||||
// TODO: Check if provided signature is already used
|
||||
|
||||
// Fetch transaction details
|
||||
const transactionResult = await connection.getParsedTransaction(transactionSignature, 'confirmed');
|
||||
|
||||
if (!transactionResult) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Transaction not found on Solana blockchain'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if transaction was successful
|
||||
if (transactionResult.meta?.err) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Transaction failed: ${JSON.stringify(transactionResult.meta.err)}`
|
||||
};
|
||||
}
|
||||
|
||||
// Check transaction timestamp (5-minute window)
|
||||
const txTimestamp = transactionResult.blockTime ? new Date(transactionResult.blockTime * 1000) : null;
|
||||
if (!txTimestamp) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Transaction timestamp not available'
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timeDiffMs = now.getTime() - txTimestamp.getTime();
|
||||
const timeWindowMs = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (timeDiffMs > timeWindowMs) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Transaction is older than 5 minutes (${Math.round(timeDiffMs / 60000)} minutes old)`
|
||||
};
|
||||
}
|
||||
|
||||
// Extract transaction info using simplified approach
|
||||
const { amount, authority } = await extractTxInfo(connection, transactionSignature);
|
||||
|
||||
// Verify amount using BN comparison like in reference
|
||||
const transactionAmount = new BN(amount);
|
||||
if (transactionAmount.lt(tokenAmount)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Payment amount (${amount}) is less than required (${tokenAmount.toString()})`
|
||||
};
|
||||
}
|
||||
|
||||
const expectedTokenAccount = getAssociatedTokenAddressSync(
|
||||
new PublicKey(process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!),
|
||||
new PublicKey(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS!),
|
||||
// Allow recipient account address to be off the curve (i.e now having a private key)
|
||||
true
|
||||
);
|
||||
|
||||
// Verify recipient address by checking the transaction instructions
|
||||
let foundValidTransfer = false;
|
||||
|
||||
for (const instruction of transactionResult.transaction.message.instructions) {
|
||||
if ('parsed' in instruction && instruction.programId.equals(TOKEN_PROGRAM_ID)) {
|
||||
const parsed = instruction.parsed;
|
||||
if (parsed.type === 'transferChecked' || parsed.type === 'transfer') {
|
||||
// Verify amount and recipient's associated token address
|
||||
if (parsed.info.amount === amount && parsed.info.destination === expectedTokenAccount.toBase58() ) {
|
||||
foundValidTransfer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundValidTransfer) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Valid Solana token transfer not found in transaction'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
amount,
|
||||
sender: authority
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error verifying Solana payment:', error);
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Failed to verify transaction: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user