diff --git a/README.md b/README.md index c798099..ca9988a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A simple Next.js frontend that allows users to pay in ATOM cryptocurrency (using - URL validation and submission - Transaction verification - Laconic Registry record creation using official `@cerc-io/registry-sdk` +- Automatic salt addition to DNS names to prevent collisions +- Error handling and validation throughout the application flow ## Prerequisites @@ -30,6 +32,7 @@ Client-side (must be prefixed with NEXT_PUBLIC_): - `NEXT_PUBLIC_RECIPIENT_ADDRESS` - The Cosmos address that will receive ATOM payments - `NEXT_PUBLIC_COSMOS_RPC_URL` - The RPC URL for the Cosmos blockchain - `NEXT_PUBLIC_COSMOS_CHAIN_ID` - The chain ID for Keplr wallet (e.g., cosmoshub-4) +- `NEXT_PUBLIC_DOMAIN_SUFFIX` - Optional suffix to append to DNS names in the UI (e.g. ".example.com") Server-side: - `REGISTRY_CHAIN_ID` - The chain ID for the Laconic Registry @@ -74,7 +77,10 @@ npm start 3. User completes payment in ATOM to a specified address 4. The application verifies the transaction using the Cosmos RPC 5. The application calls a server-side API route which creates records in the Laconic Registry -6. The record includes the URL, transaction hash, and other metadata +6. The server generates a unique DNS name by adding a random salt to prevent name collisions +7. Two records are created in the Laconic Registry: + - An ApplicationRecord containing metadata about the URL + - An ApplicationDeploymentRequest linking the URL, DNS, and payment transaction ### Architecture @@ -85,12 +91,89 @@ This application uses a hybrid client/server approach: This architecture allows us to keep sensitive keys secure on the server side while providing a responsive user experience. +### Resource Name Formats + +#### DNS Name Format + +The DNS names are generated with the following format: +``` +{sanitized-url-name}-{short-commit-hash}-{random-salt}{domain-suffix} +``` + +For example: +- Basic DNS: `github-abc123-xyz789` +- With domain suffix: `github-abc123-xyz789.example.com` + +The random salt ensures that each deployment request has a unique DNS name, even if the same URL is deployed multiple times or by different users. The optional domain suffix allows the application to display a full domain name to users. + +#### Laconic Resource Name (LRN) Format + +The Laconic Resource Names (LRNs) are generated with the following format: +``` +lrn://{authority}/applications/{app-name}-{short-commit-hash}-{random-salt} +``` + +For example: `lrn://atom/applications/github-abc123-xyz789` + +Including the commit hash and salt in the LRN ensures that each application record has a unique identifier, consistently matching the DNS naming pattern. + ## Reference Files This application was built with reference to: - `snowballtools-base/packages/backend/src/registry.ts` - `hosted-frontends/deploy-atom.sh` +## Deployment to Production + +To deploy this application to production, follow these steps: + +1. Clone the repository +2. Install dependencies: `npm install` +3. Create a production build: `npm run build` +4. Set up all required environment variables in your production environment +5. Start the production server: `npm start` + +For containerized deployments, you can use the following Dockerfile: + +```dockerfile +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +EXPOSE 3000 + +ENV NODE_ENV=production + +CMD ["npm", "start"] +``` + +Build and run the Docker container: + +```bash +docker build -t atom-deploy . +docker run -p 3000:3000 --env-file .env.production atom-deploy +``` + ## Known Issues - You may see a deprecated Buffer() warning during build. This comes from dependencies in the registry-sdk. This doesn't affect functionality. +- If using a custom Cosmos chain, ensure that your RPC endpoint supports CORS for client-side requests. +- The Keplr wallet integration requires HTTPS in production environments. + +## Troubleshooting + +### Keplr Wallet Issues + +- **Keplr not detecting**: Install the Keplr browser extension and refresh the page. +- **Chain not found in Keplr**: The application will attempt to suggest the chain to Keplr, but if that fails, you may need to manually add the chain in your Keplr wallet settings. + +### Laconic Registry Issues + +- **Failed to create record**: Check that your REGISTRY_USER_KEY and REGISTRY_BOND_ID are correctly set. +- **Transaction verification errors**: Ensure your COSMOS_RPC_URL is accessible and returns correct transaction data. diff --git a/src/app/api/registry/route.ts b/src/app/api/registry/route.ts index 802d6e9..df9c33c 100644 --- a/src/app/api/registry/route.ts +++ b/src/app/api/registry/route.ts @@ -118,7 +118,25 @@ const registryTransactionWithRetry = async ( export async function POST(request: NextRequest) { try { - const { url, txHash } = await request.json(); + // First check if the request body is valid JSON + let url, txHash; + try { + const body = await request.json(); + url = body.url; + txHash = body.txHash; + + if (!url || !txHash) { + 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 }); + } // Validate required environment variables const requiredEnvVars = [ @@ -133,9 +151,10 @@ export async function POST(request: NextRequest) { for (const envVar of requiredEnvVars) { if (!process.env[envVar]) { + console.error(`Missing environment variable: ${envVar}`); return NextResponse.json({ status: 'error', - message: `Missing environment variable: ${envVar}` + message: `Server configuration error: Missing environment variable: ${envVar}` }, { status: 500 }); } } @@ -155,9 +174,17 @@ export async function POST(request: NextRequest) { // 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})`); + // 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})`); // Ensure the DNS name doesn't have consecutive dashes or start/end with a dash let cleanDnsName = dnsName @@ -166,10 +193,10 @@ export async function POST(request: NextRequest) { // 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 keep the commit hash part - const hashPart = `-${shortHash}`; - const maxAppNameLength = 63 - hashPart.length; - cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + hashPart; + // 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 the DNS name ended up empty (unlikely) or doesn't start with a letter (possible), @@ -178,7 +205,7 @@ export async function POST(request: NextRequest) { cleanDnsName = `app-${cleanDnsName}`; } - console.log(`Final DNS name: ${cleanDnsName}`); + console.log(`Final DNS name with salt: ${cleanDnsName}`); // Set up Registry config const config = { @@ -212,8 +239,9 @@ export async function POST(request: NextRequest) { { chainId: config.chainId, gasPrice } ); - // Create LRN for the application - const lrn = `lrn://${config.authority}/applications/${appName}`; + // 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}`; // Get current timestamp for the meta note const timestamp = new Date().toUTCString(); @@ -222,7 +250,7 @@ export async function POST(request: NextRequest) { console.log('Step 1: Publishing ApplicationRecord...'); const applicationRecord = { type: 'ApplicationRecord', - name: appName, + name: `${appName}-${shortHash}-${salt}`, // Include commit hash and salt in the record name version: '1.0.0', app_type: 'webapp', repository: [repoUrl], @@ -232,8 +260,8 @@ export async function POST(request: NextRequest) { // Create fee for transaction directly const fee = { - amount: [{ denom: 'alnt', amount: '900000' }], - gas: '900000', + amount: [{ denom: 'alnt', amount: process.env.REGISTRY_FEES?.replace('alnt', '') || '900000' }], + gas: process.env.REGISTRY_GAS || '900000', }; console.log('Application record data:', applicationRecord); @@ -312,8 +340,8 @@ export async function POST(request: NextRequest) { const deploymentRequestData = { type: 'ApplicationDeploymentRequest', version: '1.0.0', - name: appName, - application: lrn, + name: `${appName}-${shortHash}-${salt}`, // Update name to match application record + application: lrn, // LRN already includes commit hash and salt deployer: deployerLrn, dns: cleanDnsName, config: { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1b9742b..42953a5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "ATOM Deploy - Laconic Registry", + title: "Deploy Frontends using ATOM and Laconic", description: "Deploy URLs to Laconic Registry using ATOM payments", }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 20a60ec..e27e255 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -99,7 +99,7 @@ export default function Home() {

- ATOM Deploy - Laconic Registry + Deploy Frontends with ATOM and Laconic

diff --git a/src/components/PaymentModal.tsx b/src/components/PaymentModal.tsx index d063322..264b452 100644 --- a/src/components/PaymentModal.tsx +++ b/src/components/PaymentModal.tsx @@ -23,7 +23,25 @@ export default function PaymentModal({ // Get recipient address from environment variables const recipientAddress = process.env.NEXT_PUBLIC_RECIPIENT_ADDRESS || 'cosmos1yourrealaddress'; + // Validate amount on change + const handleAmountChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setAmount(value); + + // Clear error when user types + if (error) { + setError(''); + } + }; + const handlePayment = async () => { + // Validate amount before sending + const parsedAmount = parseFloat(amount); + if (isNaN(parsedAmount) || parsedAmount <= 0) { + setError('Please enter a valid positive amount'); + return; + } + setLoading(true); setError(''); @@ -78,7 +96,7 @@ export default function PaymentModal({ min="0.001" step="0.001" value={amount} - onChange={(e) => setAmount(e.target.value)} + onChange={handleAmountChange} className="w-full p-3 pr-12 rounded-md" style={{ background: 'var(--card-bg)', diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx index 5d4633f..5874d3a 100644 --- a/src/components/StatusDisplay.tsx +++ b/src/components/StatusDisplay.tsx @@ -27,6 +27,8 @@ export default function StatusDisplay({ shortCommitHash, error, }: StatusDisplayProps) { + // Get domain suffix from environment variable + const domainSuffix = process.env.NEXT_PUBLIC_DOMAIN_SUFFIX || ''; if (status === 'idle') return null; const StatusBadge = ({ type }: { type: 'verifying' | 'creating' | 'success' | 'error' }) => { @@ -115,23 +117,20 @@ export default function StatusDisplay({

Successfully deployed {appName && {appName}} - {dns && as {dns}}

{repoUrl && (

Repository: {repoUrl} - {shortCommitHash && @ {shortCommitHash}} - {(!shortCommitHash && commitHash) && @ {commitHash.substring(0, 7)}}

)}
)} - {txHash && } - {appRecordId && } - {recordId && } + {txHash && } + {appRecordId && } + {recordId && } {lrn && } - {dns && } + {dns && }
)} @@ -145,4 +144,4 @@ export default function StatusDisplay({ )} ); -} \ No newline at end of file +} diff --git a/src/components/URLForm.tsx b/src/components/URLForm.tsx index 70289ac..a641897 100644 --- a/src/components/URLForm.tsx +++ b/src/components/URLForm.tsx @@ -14,18 +14,41 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - // Validate URL + // 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(url); + 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(url); + onSubmit(trimmedUrl); } catch (_) { - setError('Please enter a valid URL'); + setError('Please enter a valid URL (e.g., https://example.com)'); } }; diff --git a/src/services/keplr.ts b/src/services/keplr.ts index 6986507..fe9242f 100644 --- a/src/services/keplr.ts +++ b/src/services/keplr.ts @@ -9,12 +9,74 @@ export const connectKeplr = async (): Promise => { } 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(process.env.NEXT_PUBLIC_COSMOS_CHAIN_ID || 'cosmoshub-4'); - const offlineSigner = window.keplr.getOfflineSigner(process.env.NEXT_PUBLIC_COSMOS_CHAIN_ID || 'cosmoshub-4'); + 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); @@ -35,9 +97,62 @@ export const sendAtomPayment = async ( }; } + // 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); @@ -59,6 +174,13 @@ export const sendAtomPayment = async ( // 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) @@ -75,16 +197,55 @@ export const sendAtomPayment = async ( } ); + 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: error instanceof Error ? error.message : 'Unknown error' + message: 'Unknown error occurred while sending payment' }; } };