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 is contained in:
shreerang 2025-07-21 13:14:05 +00:00 committed by nabarun
parent 765fe3d49a
commit acabd4569f
31 changed files with 2285 additions and 1272 deletions

30
.env.example Normal file
View File

@ -0,0 +1,30 @@
# Client-side environment variables (must be prefixed with NEXT_PUBLIC_)
# Solana Token Payment Configuration
# TODO: Use different RPC URL or use browser wallet
NEXT_PUBLIC_SOLANA_RPC_URL=https://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158
NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=71Jvq4Epe2FCJ7JFSF7jLXdNk1Wy4Bhqd9iL6bEFELvg
# Multisig address
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6
NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400 # Approx. 5 USD
# UI Configuration
NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app
# Server-side environment variables
# Laconic Registry Configuration
REGISTRY_CHAIN_ID=laconic-mainnet
REGISTRY_RPC_ENDPOINT=https://laconicd-mainnet-1.laconic.com
REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/graphql
REGISTRY_GAS_PRICE=0.001
REGISTRY_BOND_ID=
REGISTRY_AUTHORITY=
REGISTRY_USER_KEY=
# Application Configuration
DEPLOYER_LRN=
NEXT_PUBLIC_DOMAIN_SUFFIX=

8
.gitignore vendored
View File

@ -31,11 +31,15 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.env.local
.env
# registry env files
.registry.env
.app.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

231
CLAUDE.md Normal file
View File

@ -0,0 +1,231 @@
# CLAUDE.md - GOR Deploy Development Guide
## Project Overview
**GOR Deploy** is a Next.js application that bridges Solana blockchain token payments with Laconic Registry deployments. Users pay with GOR tokens (or any configurable Solana SPL token) and the app creates deployment records in the Laconic Registry for frontend applications.
## Quick Commands
```bash
# Development
npm run dev # Start development server (http://localhost:3000)
npm run build # Build for production
npm start # Start production server
npm run lint # Run ESLint
# Testing
# No test framework configured - check README for testing approach
```
## Project Architecture
### Tech Stack
- **Frontend**: Next.js 15.3.1 with React 19
- **Styling**: TailwindCSS 4
- **Blockchain**:
- Solana Web3.js for payments
- @cerc-io/registry-sdk for Laconic Registry
- CosmJS for Cosmos blockchain interactions
- **Wallet Integration**: Phantom & Solflare
- **Language**: TypeScript
### Key Dependencies
- `@solana/web3.js` - Solana blockchain interactions
- `@solana/spl-token` - SPL token handling
- `@cerc-io/registry-sdk` - Laconic Registry operations
- `@cosmjs/stargate` - Cosmos blockchain operations
- `axios` - HTTP requests for GitHub API
- `bn.js` - Big number arithmetic
- `big.js` - Decimal arithmetic
## Application Flow
1. **Wallet Connection**: User connects Phantom or Solflare wallet
2. **URL Input**: User enters repository URL to deploy
3. **Payment**: Fixed amount payment in GOR/SPL tokens to configured recipient
4. **Verification**: Server verifies Solana transaction (5-minute window)
5. **LNT Transfer**: Server transfers LNT tokens for registry fees
6. **Registry Records**: Creates ApplicationRecord and ApplicationDeploymentRequest
7. **Name Mapping**: Sets LRN mappings in Laconic Registry
## Key Files and Components
### Frontend Components
- `src/app/page.tsx` - Main application page with 3-step flow
- `src/components/PaymentModal.tsx` - Solana payment interface
- `src/components/SolanaConnect.tsx` - Wallet connection UI
- `src/components/URLForm.tsx` - URL input form
- `src/components/StatusDisplay.tsx` - Deployment status display
### Services and Utils
- `src/services/solana.ts` - Solana wallet and payment operations
- `src/services/registry.ts` - Client-side registry service wrapper
- `src/services/laconicTransfer.ts` - LNT token transfer logic
- `src/utils/solanaVerify.ts` - Payment verification with replay protection
- `src/config/index.ts` - Registry configuration management
### API Routes
- `src/app/api/registry/route.ts` - Main server-side registry operations
## Environment Configuration
### Client-side Variables (NEXT_PUBLIC_*)
```bash
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=<SPL_TOKEN_MINT>
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=<RECIPIENT_WALLET>
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6
NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400
NEXT_PUBLIC_DOMAIN_SUFFIX=.example.com
NEXT_PUBLIC_EXAMPLE_URL=https://github.com/cerc-io/laconic-registry-cli
```
### Server-side Variables
```bash
REGISTRY_CHAIN_ID=laconic-mainnet
REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/api
REGISTRY_RPC_ENDPOINT=https://laconicd-mainnet-1.laconic.com
REGISTRY_BOND_ID=<BOND_ID>
REGISTRY_AUTHORITY=<AUTHORITY_NAME>
REGISTRY_USER_KEY=<PRIVATE_KEY>
APP_NAME=gor-deploy
DEPLOYER_LRN=<DEPLOYER_LRN>
```
## Code Patterns and Conventions
### State Management
- Uses React `useState` for component state
- No global state management library
- Dynamic imports to avoid SSR issues with browser APIs
### Error Handling
- Comprehensive try-catch blocks in all async operations
- User-friendly error messages in UI
- Server-side error logging with detailed context
### Security Best Practices
- Environment variables for sensitive data
- Transaction verification with replay protection
- Proper SPL token account creation and validation
- Private key never exposed to client-side
## Development Workflow
### Adding New Features
1. **Client Components**: Add to `src/components/`
2. **Services**: Add to `src/services/`
3. **API Routes**: Add to `src/app/api/`
4. **Types**: Update `src/types/index.ts`
### Payment Integration
- All payment logic in `src/services/solana.ts`
- Verification logic in `src/utils/solanaVerify.ts`
- Transaction signing via wallet adapters
### Registry Integration
- Registry operations in `src/app/api/registry/route.ts`
- Configuration in `src/config/index.ts`
- LRN format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
## DNS and Resource Naming
### DNS Name Generation
Format: `{sanitized-repo-name}-{7-char-commit-hash}`
- Sanitized to be DNS-compatible (alphanumeric + dashes)
- Max 63 characters per DNS label
- Always lowercase
- Random salt removed (commit hash provides uniqueness)
### LRN (Laconic Resource Name) Format
Format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
- Matches DNS naming for consistency
- Includes both versioned and unversioned mappings
## Transaction Flow Details
### Solana Payment Verification
- 5-minute transaction window
- Amount verification against configured payment
- Recipient address validation
- Replay attack protection (TODO: implement used transaction tracking)
### Laconic Registry Operations
1. **ApplicationRecord**: Repository metadata and commit info
2. **Name Mappings**: LRN pointing to application record
3. **ApplicationDeploymentRequest**: Deployment configuration with payment proof
## Common Development Tasks
### Adding New Wallet Support
1. Update `SolanaWalletType` in `src/types/index.ts`
2. Add wallet logic to `src/services/solana.ts`
3. Update `SolanaConnect.tsx` component
### Modifying Payment Verification
- Update logic in `src/utils/solanaVerify.ts`
- Consider replay protection requirements
- Test with various transaction types
### Registry Schema Changes
- Update record structures in `src/app/api/registry/route.ts`
- Ensure compatibility with Laconic Registry schema
- Test with registry validation
## Deployment Considerations
### Production Setup
- All environment variables must be set
- HTTPS required for wallet connections
- Consider rate limiting for API routes
- Monitor gas fees and LNT transfer costs
### Docker Deployment
- Dockerfile provided in project root
- Build optimization for Next.js production
- Environment variables via docker-compose or k8s secrets
## Troubleshooting
### Common Issues
- **Wallet connection fails**: Check HTTPS and wallet extension
- **Transaction verification fails**: Check RPC URL and transaction timing
- **Registry errors**: Verify all REGISTRY_* environment variables
- **LNT transfer fails**: Check private key balance and permissions
### Debug Tools
- Browser console for client-side debugging
- Server logs for API route debugging
- Solana Explorer for transaction verification
- Registry CLI for direct registry interactions
## Testing Strategy
- Manual testing with real wallets and tokens
- Use testnet/devnet for development
- Test transaction verification edge cases
- Verify registry record creation and name mappings
## Related Projects
This application references:
- `snowballtools-base/packages/backend/src/registry.ts`
- Original `hosted-frontends/deploy-atom.sh`
- Laconic Registry CLI tools
## Security Considerations
- Private keys stored securely server-side only
- Transaction replay protection implemented
- Input validation on all user inputs
- Secure RPC endpoint usage
- Wallet signature verification
## Future Enhancements
- Implement transaction replay protection database
- Add support for custom deployment configurations
- Enhanced error recovery and retry logic
- Monitoring and analytics integration
- Multi-chain payment support

View File

@ -1,13 +1,15 @@
# ATOM Deploy - Laconic Registry
# GOR Deploy - Laconic Registry
A simple Next.js frontend that allows users to pay in ATOM cryptocurrency (using Keplr wallet) and paste a URL. The transaction hash and URL are used to create records in the Laconic Registry.
A simple Next.js frontend that allows users to pay in GOR tokens (configurable Solana SPL tokens) using Solana wallets and paste a URL. The transaction hash and URL are used to create records in the Laconic Registry.
## Features
- Keplr wallet integration for ATOM payments
- Solana wallet integration (Phantom & Solflare) for GOR token payments
- Configurable Solana SPL token support (defaults to GOR)
- URL validation and submission
- Transaction verification
- Solana transaction verification with replay protection
- Laconic Registry record creation using official `@cerc-io/registry-sdk`
- LNT token transfer integration for registry payments
- Automatic salt addition to DNS names to prevent collisions
- Error handling and validation throughout the application flow
@ -15,7 +17,7 @@ A simple Next.js frontend that allows users to pay in ATOM cryptocurrency (using
- Node.js 18.x or later
- npm or yarn
- Keplr wallet browser extension
- Solana wallet browser extension (Phantom or Solflare)
- Access to a Laconic Registry node
## Environment Variables
@ -29,10 +31,12 @@ cp .env.local.example .env.local
Required environment variables:
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 (used by Keplr for transactions)
- `NEXT_PUBLIC_COSMOS_API_URL` - The REST API URL for the Cosmos blockchain (used for transaction queries)
- `NEXT_PUBLIC_COSMOS_CHAIN_ID` - The chain ID for Keplr wallet (e.g., cosmoshub-4)
- `NEXT_PUBLIC_SOLANA_RPC_URL` - The RPC URL for the Solana blockchain
- `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` - The mint address of the SPL token to accept
- `NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS` - The Solana address that will receive token payments
- `NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL` - The token symbol to display (e.g., "GOR")
- `NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS` - The number of decimals for the token (e.g., 6)
- `NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT` - The fixed payment amount required (e.g., 400)
- `NEXT_PUBLIC_DOMAIN_SUFFIX` - Optional suffix to append to DNS names in the UI (e.g. ".example.com")
- `NEXT_PUBLIC_EXAMPLE_URL` - Example URL to pre-fill in the URL form (e.g. "https://github.com/cerc-io/laconic-registry-cli")
@ -42,8 +46,8 @@ Server-side:
- `REGISTRY_RPC_ENDPOINT` - The RPC endpoint for the Laconic Registry
- `REGISTRY_BOND_ID` - The bond ID to use for Laconic Registry records
- `REGISTRY_AUTHORITY` - The authority for Laconic Registry LRNs
- `REGISTRY_USER_KEY` - The private key for Laconic Registry transactions
- `APP_NAME` - The name of the application (used in record creation)
- `REGISTRY_USER_KEY` - The private key for Laconic Registry transactions (also used for LNT transfers)
- `APP_NAME` - The name of the application (used in record creation, defaults to "gor-deploy")
- `DEPLOYER_LRN` - The LRN of the deployer
## Installation
@ -74,24 +78,25 @@ npm start
## How It Works
1. User connects their Keplr wallet to the application
1. User connects their Solana wallet (Phantom or Solflare) to the application
2. User enters a URL they want to deploy to the Laconic Registry
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 server generates a unique DNS name by adding a random salt to prevent name collisions
7. Two records are created in the Laconic Registry:
3. User completes payment in GOR tokens (or configured SPL token) to a specified Solana address
4. The application verifies the Solana transaction with replay protection
5. After payment verification, the server transfers LNT tokens from a prefilled account to the service provider
6. The application calls a server-side API route which creates records in the Laconic Registry using the LNT transfer hash
7. The server generates a unique DNS name by adding a random salt to prevent name collisions
8. Two records are created in the Laconic Registry:
- An ApplicationRecord containing metadata about the URL
- An ApplicationDeploymentRequest linking the URL, DNS, and payment transaction
- An ApplicationDeploymentRequest linking the URL, DNS, and payment details with external_payment metadata
### Architecture
This application uses a hybrid client/server approach:
- Client-side: Handles the user interface, Keplr wallet integration, and transaction verification
- Server-side: Next.js API route handles the communication with the Laconic Registry
- Client-side: Handles the user interface, Solana wallet integration, and transaction verification
- Server-side: Next.js API route handles LNT transfers and communication with the Laconic Registry
This architecture allows us to keep sensitive keys secure on the server side while providing a responsive user experience.
This architecture allows us to keep sensitive keys secure on the server side while providing a responsive user experience. The dual-payment system (Solana → LNT → Registry) enables cross-chain payment acceptance.
### Resource Name Formats
@ -115,7 +120,7 @@ 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`
For example: `lrn://gor/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.
@ -123,7 +128,7 @@ Including the commit hash and salt in the LRN ensures that each application reco
This application was built with reference to:
- `snowballtools-base/packages/backend/src/registry.ts`
- `hosted-frontends/deploy-atom.sh`
- Original `hosted-frontends/deploy-atom.sh` (adapted for Solana/GOR)
## Deployment to Production
@ -158,24 +163,32 @@ 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
docker build -t gor-deploy .
docker run -p 3000:3000 --env-file .env.production gor-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.
- Ensure that your Solana RPC endpoint supports CORS for client-side requests.
- Solana wallet integrations require HTTPS in production environments.
## Troubleshooting
### Keplr Wallet Issues
### Solana 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.
- **Wallet not detecting**: Install the Phantom or Solflare browser extension and refresh the page.
- **Connection issues**: Ensure the wallet is unlocked and try refreshing the page.
- **Transaction failures**: Check that you have sufficient SOL for transaction fees and enough tokens for the payment.
### Token Configuration
- **Wrong token**: Verify the `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` matches your desired SPL token.
- **Incorrect decimals**: Ensure `NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS` matches the token's actual decimal count.
- **Payment amount**: Adjust `NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT` to the desired payment amount.
### 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 and COSMOS_API_URL are accessible and return correct transaction data.
- **LNT transfer errors**: Ensure your REGISTRY_USER_KEY has sufficient LNT balance.
- **Transaction verification errors**: Ensure your SOLANA_RPC_URL is accessible and returns correct transaction data.

View File

@ -0,0 +1,10 @@
# ENV for registry operations
# Bond to use
REGISTRY_BOND_ID=
# Target deployer LRN
DEPLOYER_LRN=
# Authority to deploy the app under
AUTHORITY=

40
deploy/Dockerfile Normal file
View File

@ -0,0 +1,40 @@
ARG VARIANT=20-bullseye
FROM node:${VARIANT}
ARG USERNAME=node
ARG NPM_GLOBAL=/usr/local/share/npm-global
# Add NPM global to PATH.
ENV PATH=${NPM_GLOBAL}/bin:${PATH}
RUN \
# Configure global npm install location, use group to adapt to UID/GID changes
if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \
&& usermod -a -G npm ${USERNAME} \
&& umask 0002 \
&& mkdir -p ${NPM_GLOBAL} \
&& touch /usr/local/etc/npmrc \
&& chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \
&& chmod g+s ${NPM_GLOBAL} \
&& npm config -g set prefix ${NPM_GLOBAL} \
&& su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \
# Install eslint
&& su ${USERNAME} -c "umask 0002 && npm install -g eslint" \
&& npm cache clean --force > /dev/null 2>&1
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends jq bash
# laconic-so
RUN curl -LO https://git.vdb.to/cerc-io/stack-orchestrator/releases/download/latest/laconic-so && \
chmod +x ./laconic-so && \
mv ./laconic-so /usr/bin/laconic-so
# Configure the npm registry
RUN npm config set @cerc-io:registry https://git.vdb.to/api/packages/cerc-io/npm/
# DEBUG, remove
RUN yarn info @cerc-io/laconic-registry-cli
# Globally install the cli package
RUN yarn global add @cerc-io/laconic-registry-cli

75
deploy/README.md Normal file
View File

@ -0,0 +1,75 @@
# Deploy
## Setup
### gor-deploy
* Clone the repo:
```bash
git clone git@git.vdb.to:LaconicNetwork/gor-deploy.git
cd gor-deploy/deploy
```
* Build registry CLI image:
```bash
docker build -t cerc/laconic-registry-cli .
# Builds image cerc/laconic-registry-cli:latest
```
* Configure `userKey` and `bondId` in the [registry CLI config](./config.yml):
```bash
nano config.yml
```
* Add configuration for registry operations:
```bash
cp .registry.env.example .registry.env
# Fill in the required values
nano .registry.env
```
* Add configuration for the app:
```bash
curl -s https://git.vdb.to/LaconicNetwork/gor-deploy/src/branch/main/.env.example -o .app.env
# Fill in the required values
nano .app.env
```
## Run
### gor-deploy
* Deploy `gor-deploy` App:
```bash
# In gor-deploy/deploy dir
docker run -it \
-v ./:/app/deploy -w /app/deploy \
-e DEPLOYMENT_DNS=gor-deploy \
cerc/laconic-registry-cli:latest \
./deploy.sh
```
* Check deployment logs on deployer UI: <https://webapp-deployer-ui.apps.vaasl.io/>
* Visit deployed app: <https://gor-deploy.apps.vaasl.io>
### remove deployment
* Remove deployment:
```bash
# In gor-deploy/deploy dir
docker run -it \
-v ./:/app/deploy -w /app/deploy \
-e DEPLOYMENT_RECORD_ID=<deploment-record-id-to-be-removed> \
cerc/laconic-registry-cli:latest \
./remove-deployment.sh
```

9
deploy/config.yml Normal file
View File

@ -0,0 +1,9 @@
# Registry CLI config
services:
registry:
rpcEndpoint: 'https://laconicd-mainnet-1.laconic.com'
gqlEndpoint: 'https://laconicd-mainnet-1.laconic.com/api'
userKey:
bondId:
chainId: laconic-mainnet
gasPrice: 0.001alnt

128
deploy/deploy.sh Executable file
View File

@ -0,0 +1,128 @@
#!/bin/bash
# Fail on error
set -e
source .registry.env
echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID"
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
echo "Using AUTHORITY: $AUTHORITY"
# Repository URL
REPO_URL="https://git.vdb.to/LaconicNetwork/gor-deploy"
# Get the latest commit hash for a branch
BRANCH_NAME="main"
LATEST_HASH=$(git ls-remote $REPO_URL refs/heads/$BRANCH_NAME | awk '{print $1}')
PACKAGE_VERSION=$(curl -s $REPO_URL/raw/branch/$BRANCH_NAME/package.json | jq -r .version)
APP_NAME=gor-deploy
echo "Repo: ${REPO_URL}"
echo "Latest hash: ${LATEST_HASH}"
echo "App version: ${PACKAGE_VERSION}"
echo "Deployment DNS: ${DEPLOYMENT_DNS}"
# Current date and time for note
CURRENT_DATE_TIME=$(date -u)
CONFIG_FILE=config.yml
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found
NEW_APPLICATION_VERSION=0.0.1
fi
# Generate application-record.yml with incremented version
mkdir -p records
RECORD_FILE=./records/application-record.yml
cat >$RECORD_FILE <<EOF
record:
type: ApplicationRecord
version: $NEW_APPLICATION_VERSION
repository_ref: $LATEST_HASH
repository: ["$REPO_URL"]
app_type: webapp
name: $APP_NAME
app_version: $PACKAGE_VERSION
EOF
echo "Application record generated successfully: $RECORD_FILE"
# Publish ApplicationRecord
publish_response=$(laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to publish record"
exit $rc
fi
RECORD_ID=$(echo $publish_response | jq -r '.id')
echo "ApplicationRecord published, setting names next"
echo $RECORD_ID
# Set name to record
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/$APP_NAME"
name1="$REGISTRY_APP_LRN@${PACKAGE_VERSION}"
sleep 2
laconic -c $CONFIG_FILE registry name set "$name1" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
exit $rc
fi
echo "$name1 set for ApplicationRecord"
name2="$REGISTRY_APP_LRN@${LATEST_HASH}"
sleep 2
laconic -c $CONFIG_FILE registry name set "$name2" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set hash"
exit $rc
fi
echo "$name2 set for ApplicationRecord"
name3="$REGISTRY_APP_LRN"
sleep 2
# Set name if latest release
laconic -c $CONFIG_FILE registry name set "$name3" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set release"
exit $rc
fi
echo "$name3 set for ApplicationRecord"
# Check if record found for REGISTRY_APP_LRN
query_response=$(laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN")
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query name"
exit $rc
fi
APP_RECORD=$(echo $query_response | jq '.[0]')
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
echo "No record found for $REGISTRY_APP_LRN."
exit 1
fi
echo "Name resolution successful"
sleep 2
echo "Requesting a webapp deployment for $name2, using deployer $DEPLOYER_LRN"
laconic-so request-webapp-deployment \
--laconic-config $CONFIG_FILE \
--deployer $DEPLOYER_LRN \
--app $name2 \
--env-file ./.app.env \
--dns $DEPLOYMENT_DNS \
--make-payment auto
echo "Done"

0
deploy/records/.gitkeep Normal file
View File

63
deploy/remove-deployment.sh Executable file
View File

@ -0,0 +1,63 @@
#!/bin/bash
set -e
if [[ -z $DEPLOYMENT_RECORD_ID ]]; then
echo "Error: please pass the deployment record ID" >&2
exit 1
fi
source .registry.env
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
echo "Deployment record ID: $DEPLOYMENT_RECORD_ID"
# Generate application-deployment-removal-request.yml
REMOVAL_REQUEST_RECORD_FILE=./records/application-deployment-removal-request.yml
cat > $REMOVAL_REQUEST_RECORD_FILE <<EOF
record:
deployer: $DEPLOYER_LRN
deployment: $DEPLOYMENT_RECORD_ID
type: ApplicationDeploymentRemovalRequest
version: 1.0.0
EOF
CONFIG_FILE=config.yml
sleep 2
REMOVAL_REQUEST_ID=$(laconic -c $CONFIG_FILE registry record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id')
echo "ApplicationDeploymentRemovalRequest published"
echo $REMOVAL_REQUEST_ID
# Deployment checks
RETRY_INTERVAL=30
MAX_RETRIES=20
# Check that an ApplicationDeploymentRemovalRecord is published
retry_count=0
while true; do
removal_records_response=$(laconic -c $CONFIG_FILE registry record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID)
len_removal_records=$(echo $removal_records_response | jq 'length')
# Check if number of records returned is 0
if [ $len_removal_records -eq 0 ]; then
# Check if retries are exhausted
if [ $retry_count -eq $MAX_RETRIES ]; then
echo "Retries exhausted"
echo "ApplicationDeploymentRemovalRecord for deployment removal request $REMOVAL_REQUEST_ID not found"
exit 1
else
echo "ApplicationDeploymentRemovalRecord not found, retrying in $RETRY_INTERVAL sec..."
sleep $RETRY_INTERVAL
retry_count=$((retry_count+1))
fi
else
echo "ApplicationDeploymentRemovalRecord found"
REMOVAL_RECORD_ID=$(echo $removal_records_response | jq -r '.[0].id')
echo $REMOVAL_RECORD_ID
break
fi
done
echo "Deployment removal successful"

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,6 +1,6 @@
import type { NextConfig } from "next";
/** @type {import('next').NextConfig} */
const nextConfig: NextConfig = {
const nextConfig = {
/* config options here */
typescript: {
// !! WARN !!

1024
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "atom-deploy",
"name": "gor-deploy",
"version": "0.1.0",
"private": true,
"scripts": {
@ -11,8 +11,11 @@
"dependencies": {
"@cerc-io/registry-sdk": "^0.2.11",
"@cosmjs/stargate": "^0.32.3",
"@keplr-wallet/types": "^0.12.71",
"@solana/spl-token": "^0.4.13",
"@solana/web3.js": "^1.98.2",
"axios": "^1.6.8",
"big.js": "^6.2.2",
"bn.js": "^5.2.2",
"next": "15.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View File

@ -1,120 +1,25 @@
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 {
@ -202,7 +107,7 @@ 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
@ -225,15 +130,37 @@ const registryTransactionWithRetry = async (
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',
@ -241,40 +168,62 @@ export async function POST(request: NextRequest) {
}, { status: 400 });
}
} catch (error) {
console.error(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);
// 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!);
if (!paymentVerificationResult.valid) {
console.error('ATOM payment verification failed:', paymentVerificationResult.reason);
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: ${paymentVerificationResult.reason}`
message: `Payment verification failed: ${solanaPaymentResult.reason}`
}, { status: 400 });
}
console.log('ATOM payment verified successfully:', {
amount: paymentVerificationResult.amount,
sender: paymentVerificationResult.sender
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) {
@ -302,17 +251,9 @@ 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();
// 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
@ -321,10 +262,9 @@ 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 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),
@ -333,22 +273,10 @@ export async function POST(request: NextRequest) {
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,
@ -358,18 +286,13 @@ export async function POST(request: NextRequest) {
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 }
);
const registry = getRegistry()
// 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}`;
// 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();
@ -378,7 +301,7 @@ export async function POST(request: NextRequest) {
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],
@ -386,12 +309,6 @@ export async function POST(request: NextRequest) {
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
@ -405,7 +322,6 @@ export async function POST(request: NextRequest) {
bondId: config.bondId,
},
config.privateKey,
fee
)
) as { id?: string };
@ -437,7 +353,6 @@ export async function POST(request: NextRequest) {
lrn
},
config.privateKey,
fee
)
);
console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`);
@ -450,7 +365,6 @@ export async function POST(request: NextRequest) {
lrn: `${lrn}@${fullHash}`
},
config.privateKey,
fee
)
);
console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`);
@ -468,21 +382,25 @@ export async function POST(request: NextRequest) {
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);
@ -498,7 +416,6 @@ export async function POST(request: NextRequest) {
bondId: config.bondId,
},
config.privateKey,
fee
)
) as { id?: string };

View File

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

View File

@ -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,14 +42,17 @@ 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') {
@ -73,12 +83,11 @@ export default function Home() {
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);
@ -89,7 +98,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)' }}>
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)' }}>
@ -98,10 +107,34 @@ export default function Home() {
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>
<div className="mb-8 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)', opacity: walletAddress ? '1' : '0.6' }}>
) : (
<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: 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>
@ -109,7 +142,7 @@ export default function Home() {
</h2>
<URLForm
onSubmit={handleUrlSubmit}
disabled={!walletAddress || status === 'creating'}
disabled={!solanaWalletState.connected || status === 'creating'}
/>
</div>
@ -137,12 +170,13 @@ export default function Home() {
)}
</div>
{showPaymentModal && walletAddress && url && (
{showPaymentModal && url && solanaWalletState.connected && (
<PaymentModal
isOpen={showPaymentModal}
onClose={handleClosePaymentModal}
url={url}
onPaymentComplete={handlePaymentComplete}
walletState={solanaWalletState}
/>
)}
</main>

View File

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

View File

@ -1,64 +1,56 @@
'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';
const connection = useMemo(() => new Connection(SOLANA_RPC_URL), [])
// Validate amount on change
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setAmount(value);
// 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!);
// 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;
}
const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
const handlePayment = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await sendAtomPayment(recipientAddress, amount);
const fullAmount = amount * Math.pow(10, decimals);
const tokenAmount = new BN(fullAmount);
if (result.status === 'success' && result.hash) {
onPaymentComplete(result.hash);
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;
@ -67,7 +59,9 @@ export default function PaymentModal({
<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">
@ -87,27 +81,30 @@ export default function PaymentModal({
<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={{
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 && (
@ -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>

View File

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

View File

@ -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' }) => {
@ -125,11 +125,15 @@ 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>
)}

View File

@ -10,7 +10,7 @@ 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) => {
@ -49,7 +49,8 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
// All validations passed
setError('');
onSubmit(trimmedUrl);
} catch (_) {
} catch (error) {
console.error(error);
setError('Please enter a valid URL (e.g., https://example.com)');
}
};

View File

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

View File

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

View File

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

View File

@ -1,12 +1,11 @@
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', {
@ -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;
}
};

226
src/services/solana.ts Normal file
View File

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

View File

@ -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;
@ -52,3 +44,31 @@ export interface CreateRecordResponse {
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;
}

131
src/utils/solanaVerify.ts Normal file
View File

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

View File

@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.mjs"],
"exclude": ["node_modules"]
}