This commit is contained in:
zramsay 2025-05-05 16:47:39 -04:00
parent 43e4b38e96
commit f95bde74f7
8 changed files with 348 additions and 36 deletions

View File

@ -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.

View File

@ -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: {

View File

@ -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",
};

View File

@ -99,7 +99,7 @@ export default function Home() {
<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)' }}>
ATOM Deploy - Laconic Registry
Deploy Frontends with ATOM and Laconic
</h1>
<div className="mb-10 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}>

View File

@ -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<HTMLInputElement>) => {
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)',

View File

@ -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({
<h3 className="font-semibold mb-2 text-lg">
Successfully deployed
{appName && <span> {appName}</span>}
{dns && <span> as {dns}</span>}
</h3>
{repoUrl && (
<p className="mb-1 text-sm">
<span className="font-medium">Repository:</span> {repoUrl}
{shortCommitHash && <span> @ {shortCommitHash}</span>}
{(!shortCommitHash && commitHash) && <span> @ {commitHash.substring(0, 7)}</span>}
</p>
)}
</div>
)}
{txHash && <InfoItem label="Transaction Hash" value={txHash} />}
{appRecordId && <InfoItem label="Application Record ID" value={appRecordId} />}
{recordId && <InfoItem label="Deployment Request Record ID" value={recordId} />}
{txHash && <InfoItem label="ATOM 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 DNS" value={dns} />}
{dns && <InfoItem label="Deployment DNS" value={domainSuffix ? `${dns}${domainSuffix}` : dns} />}
</div>
)}

View File

@ -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)');
}
};

View File

@ -9,12 +9,74 @@ export const connectKeplr = async (): Promise<string | 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(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: error instanceof Error ? error.message : 'Unknown 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'
};
}
};