Compare commits

...

9 Commits

Author SHA1 Message Date
ed08ace0a4 Improve checks and warnings while connecting to wallets (#10)
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: #10
Co-authored-by: shreerang <shreerang@noreply.git.vdb.to>
Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
2025-07-30 03:12:14 +00:00
ac5dfe6966 Fix bond id in deploy steps (#8)
Reviewed-on: #8
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-07-27 13:20:43 +00:00
bc0d10d4c3 Add published application record (#7)
Reviewed-on: #7
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-07-25 16:17:41 +00:00
f6c6147340 Add authority and bond ID in readme for deployment (#6)
Reviewed-on: #6
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-07-25 15:04:10 +00:00
452c4db5f8 Add steps to publish PricingRecord and use it for determining cost of deployments (#4)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: #4
Co-authored-by: shreerang <shreerang@noreply.git.vdb.to>
Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
2025-07-25 13:36:35 +00:00
fbe4eed31d Add option to accept payments in Gorbagana chain native tokens (#5)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: #5
Co-authored-by: shreerang <shreerang@noreply.git.vdb.to>
Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
2025-07-25 04:57:40 +00:00
b4c6b0aa4e Fix route imports (#3)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: #3
Co-authored-by: shreerang <shreerang@noreply.git.vdb.to>
Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
2025-07-22 10:38:09 +00:00
ac9d4ea8eb Pull required amount of tokens using jupiter API (#2)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: #2
Co-authored-by: shreerang <shreerang@noreply.git.vdb.to>
Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
2025-07-22 10:16:14 +00:00
acabd4569f 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>
2025-07-21 13:14:05 +00:00
44 changed files with 7492 additions and 1337 deletions

32
.env.example Normal file
View File

@ -0,0 +1,32 @@
# Client-side environment variables must be prefixed with NEXT_PUBLIC_
# Solana Payment Configuration
# TODO: Use different RPC URL
NEXT_PUBLIC_SOLANA_RPC_URL=https://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158
NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=71Jvq4Epe2FCJ7JFSF7jLXdNk1Wy4Bhqd9iL6bEFELvg
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
# Gorbagana Chain Configuration
NEXT_PUBLIC_GORBAGANA_RPC_URL=https://rpc.gorbagana.wtf
NEXT_PUBLIC_ENABLE_NATIVE_GOR_TRANSFER=true
# Multisig Address
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY
# UI Configuration
NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app
# Laconic Registry Configuration
NEXT_PUBLIC_REGISTRY_CHAIN_ID=laconic-mainnet
NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT=https://laconicd-mainnet-1.laconic.com
NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/graphql
NEXT_PUBLIC_ALNT_COST_LRN=lrn://laconic/pricing/alnt
NEXT_PUBLIC_DEPLOYMENT_COST_LRN=lrn://laconic/pricing/webapp-deployment
REGISTRY_GAS_PRICE=0.001
REGISTRY_AUTHORITY=laconic-deploy
REGISTRY_BOND_ID=5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2
REGISTRY_USER_KEY=
# Application Configuration
DEPLOYER_LRN=lrn://vaasl-provider/deployers/webapp-deployer-api.apps.vaasl.io
NEXT_PUBLIC_DOMAIN_SUFFIX=apps.vaasl.io

View File

@ -1,14 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["next/core-web-vitals"],
rules: {
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}],
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"react-hooks/exhaustive-deps": "warn"
}
};

11
.gitignore vendored
View File

@ -31,11 +31,18 @@ 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
# Reveal file out dir
out

305
CLAUDE.md Normal file
View File

@ -0,0 +1,305 @@
# 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 native GOR tokens or GOR SPL tokens 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 & Backpack with context-based filtering
- **Language**: TypeScript
### Key Dependencies
- `@solana/web3.js` - Solana blockchain interactions
- `@solana/spl-token` - SPL token handling
- `@solana/wallet-adapter-react` - Unified wallet adapter integration
- `@solana/wallet-adapter-phantom` - Phantom wallet adapter
- `@solana/wallet-adapter-solflare` - Solflare wallet adapter
- `@solana/wallet-adapter-backpack` - Backpack wallet adapter
- `@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. **Payment Method Selection**: User chooses between native GOR or SPL token payment
2. **Wallet Connection**: Compatible wallets are filtered based on payment method
3. **URL Input**: User enters repository URL to deploy (enabled after wallet connection)
4. **Payment**: Fixed amount payment in native GOR or GOR SPL tokens to configured recipient
5. **Verification**: Server verifies Solana transaction (5-minute window)
6. **LNT Transfer**: Server transfers LNT tokens for registry fees
7. **Registry Records**: Creates ApplicationRecord and ApplicationDeploymentRequest
8. **Name Mapping**: Sets LRN mappings in Laconic Registry
## Payment Method & Wallet Compatibility
### Native GOR Payments (`nat-gor`)
- **Amount**: 0.01 GOR (10,000,000 lamports)
- **Compatible Wallets**: Backpack only
- **RPC Endpoint**: Uses `NEXT_PUBLIC_GORBAGANA_RPC_URL` for native transactions
### SPL Token Payments (`spl-token`)
- **Amount**: $5 USD worth of GOR SPL tokens (dynamic pricing via Jupiter)
- **Compatible Wallets**: Phantom, Solflare (Backpack excluded)
- **RPC Endpoint**: Uses `NEXT_PUBLIC_SOLANA_RPC_URL` for token transactions
## Key Files and Components
### Frontend Components
- `src/app/page.tsx` - Main application page with 4-step flow (Payment Method → Wallet → URL → Status)
- `src/components/PaymentModal.tsx` - Solana payment interface with pre-selected payment method
- `src/components/WalletProviders.tsx` - Context-aware wallet provider with filtering
- `src/components/URLForm.tsx` - URL input form
- `src/components/StatusDisplay.tsx` - Deployment status display
### Context & State Management
- `src/contexts/PaymentMethodContext.tsx` - Global payment method state management
- Uses React Context for payment method selection
- Wallet filtering based on payment method context
### Services and Utils
- `src/services/solana.ts` - Unified wallet adapter integration for payments
- `src/services/registry.ts` - Client-side registry service wrapper
- `src/services/laconic-transfer.ts` - LNT token transfer logic
- `src/utils/solana-verify.ts` - Payment verification with replay protection
- `src/config/index.ts` - Registry configuration management
- `src/constants/payments.ts` - Payment method types and configuration
### API Routes
- `src/app/api/registry/route.ts` - Main server-side registry operations with dual payment support
## Environment Configuration
### Client-side Variables (NEXT_PUBLIC_*)
```bash
NEXT_PUBLIC_SOLANA_RPC_URL=https://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158
NEXT_PUBLIC_GORBAGANA_RPC_URL=https://rpc.gorbagana.wtf
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_PAYMENT_AMOUNT_USD=5
NEXT_PUBLIC_DOMAIN_SUFFIX=.example.com
NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app
```
### Server-side Variables
```bash
NEXT_PUBLIC_REGISTRY_CHAIN_ID=laconic-mainnet
NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/api
NEXT_PUBLIC_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 Context (`PaymentMethodContext`) for global payment method state
- React `useState` for component-level state
- Dynamic imports to avoid SSR issues with browser APIs
- Context-driven wallet filtering
### Payment Architecture
- Unified wallet adapter pattern for all three wallets
- Payment method determines wallet availability
- Separate RPC endpoints for native vs SPL token transactions
- Dynamic pricing via Jupiter API for SPL tokens
### Error Handling
- Comprehensive try-catch blocks in all async operations
- User-friendly error messages in UI
- Server-side error logging with detailed context
- Wallet compatibility validation with clear feedback
### 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
- Wallet signature verification via adapters
## 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`
5. **Payment Methods**: Update `src/constants/payments.ts`
### Payment Integration
- All payment logic unified in `src/services/solana.ts`
- Verification logic in `src/utils/solana-verify.ts`
- Transaction signing via wallet adapters
- Payment method context drives wallet selection
### Wallet Integration
- Add new wallet adapters to `src/components/WalletProviders.tsx`
- Update wallet filtering logic based on payment method compatibility
- Use `@solana/wallet-adapter-*` packages for consistent integration
### 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 (different for native vs SPL)
- Replay attack protection (TODO: implement used transaction tracking)
- Payment method-specific validation logic
### 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. Install wallet adapter package: `npm install @solana/wallet-adapter-{wallet-name}`
2. Add to `src/components/WalletProviders.tsx` wallets array
3. Update wallet filtering logic based on payment method compatibility
4. Test with both payment methods
### Adding New Payment Methods
1. Update `PaymentMethod` type in `src/constants/payments.ts`
2. Add payment method label to `PAYMENT_METHOD_LABELS`
3. Update wallet filtering logic in `WalletProviders.tsx`
4. Add payment processing logic in `src/services/solana.ts`
5. Update verification logic in `src/utils/solana-verify.ts`
### Modifying Payment Verification
- Update logic in `src/utils/solana-verify.ts`
- Consider replay protection requirements
- Test with various transaction types
- Handle both native GOR and SPL token verification
### Registry Schema Changes
- Update record structures in `src/app/api/registry/route.ts`
- Ensure compatibility with Laconic Registry schema
- Test with registry validation
## Payment Method Context Usage
The `PaymentMethodContext` drives wallet filtering:
```typescript
// In components
const { selectedPaymentMethod, setSelectedPaymentMethod } = usePaymentMethod();
// Wallet filtering logic
if (selectedPaymentMethod === 'nat-gor') {
// Show only Backpack wallet
} else if (selectedPaymentMethod === 'spl-token') {
// Show only Phantom and Solflare wallets
}
```
## 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
- Ensure both RPC endpoints are reliable
### 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
- **Wrong wallet shown**: Verify payment method context is set correctly
- **Transaction verification fails**: Check appropriate RPC URL and transaction timing
- **Registry errors**: Verify all REGISTRY_* environment variables
- **LNT transfer fails**: Check private key balance and permissions
- **SPL token price errors**: Verify Jupiter API connectivity
### 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
- Payment method context state in React DevTools
## 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
- Test wallet filtering with different payment methods
- Test both native GOR and SPL token payment flows
## 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 via adapters
- Payment method validation before processing
## 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
- Additional wallet adapter integrations
- Advanced payment method configurations

147
README.md
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,36 +17,12 @@ 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
## Deploy to production
Copy the `.env.local.example` file to `.env.local` and fill in the required variables:
```bash
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_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")
Server-side:
- `REGISTRY_CHAIN_ID` - The chain ID for the Laconic Registry
- `REGISTRY_GQL_ENDPOINT` - The GraphQL endpoint for the Laconic Registry
- `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)
- `DEPLOYER_LRN` - The LRN of the deployer
Follow [these steps](./deploy/README.md) to deploy this app to production
## Installation
@ -54,6 +32,35 @@ npm install
## Development
Copy the `.env.example` file to `.env.local` and fill in the required variables:
```bash
cp .env.example .env.local
```
Required environment variables:
Client-side (must be prefixed with NEXT_PUBLIC_):
- `NEXT_PUBLIC_SOLANA_RPC_URL` - The RPC URL for the Solana blockchain (SPL token transactions)
- `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` - The mint address of the SPL token to accept
- `NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL` - The token symbol to display (e.g., "GOR")
- `NEXT_PUBLIC_GORBAGANA_RPC_URL` - The RPC URL for the Gorbagana blockchain (native GOR transactions)
- `NEXT_PUBLIC_ENABLE_NATIVE_GOR_TRANSFER` - Enable native GOR token transfers (true/false)
- `NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS` - The Solana address that will receive token payments
- `NEXT_PUBLIC_EXAMPLE_URL` - Example URL to pre-fill in the URL form
- `NEXT_PUBLIC_REGISTRY_CHAIN_ID` - The laconicd chain ID for the Laconic Registry
- `NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT` - The laconicd RPC endpoint for the Laconic Registry
- `NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT` - The laconicd GraphQL endpoint for the Laconic Registry
- `NEXT_PUBLIC_ALNT_COST_LRN` - LRN for ALNT token pricing
- `NEXT_PUBLIC_DEPLOYMENT_COST_LRN` - LRN for deployment cost pricing
- `NEXT_PUBLIC_DOMAIN_SUFFIX` - Optional suffix to append to DNS names in the UI (e.g. ".example.com")
Server-side:
- `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 (also used for LNT transfers)
- `DEPLOYER_LRN` - The LRN of the deployer
```bash
npm run dev
```
@ -74,24 +81,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
@ -102,7 +110,7 @@ The DNS names are generated with the following format:
{sanitized-url-name}-{short-commit-hash}-{random-salt}{domain-suffix}
```
For example:
For example:
- Basic DNS: `github-abc123-xyz789`
- With domain suffix: `github-abc123-xyz789.example.com`
@ -115,7 +123,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,59 +131,24 @@ 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`
## 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
```
- Original `hosted-frontends/deploy-atom.sh` (adapted for Solana/GOR)
## 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.
### 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=5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2
# Target deployer LRN
DEPLOYER_LRN=lrn://vaasl-provider/deployers/webapp-deployer-api.apps.vaasl.io
# Authority to deploy the app under
AUTHORITY=laconic-deploy

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

110
deploy/README.md Normal file
View File

@ -0,0 +1,110 @@
# 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):
- User key should be of the account that owns the `laconic-deploy` authority (owner account address: `laconic1kwx2jm6vscz38qlyujvq6msujmk8l3zangqahs`)
- The bond should also be owned by same user (owned bond's ID: `5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2`)
- If the authority is not available, follow [these steps to reserve a new authority](./publish-pricing.md#reserve-a-new-authority-optional)
```bash
nano config.yml
```
- This project requires pricing records for cost of deployment and cost of alnt to be published
- Check if these records are available by running following commands:
```bash
# Check if `lrn://laconic/pricing/webapp-deployment` is available
./laconic-cli.sh name resolve lrn://laconic/pricing/webapp-deployment
# Check if `lrn://laconic/pricing/alnt` is available
./laconic-cli.sh name resolve lrn://laconic/pricing/alnt
```
- If these records are not available, [follow these steps to publish them](./publish-pricing.md)
- Add configuration for registry operations:
```bash
cp .registry.env.example .registry.env
# Update values if required
nano .registry.env
```
- Add configuration for the app:
```bash
curl -s https://git.vdb.to/LaconicNetwork/gor-deploy/raw/branch/main/.env.example -o .app.env
# Fill in the required values
nano .app.env
```
- Required environment variables:
Client-side (must be prefixed with NEXT_PUBLIC_):
- `NEXT_PUBLIC_SOLANA_RPC_URL` - The RPC URL for the Solana blockchain (SPL token transactions)
- `NEXT_PUBLIC_GORBAGANA_RPC_URL` - The RPC URL for the Gorbagana blockchain (native GOR transactions)
- `NEXT_PUBLIC_ENABLE_NATIVE_GOR_TRANSFER` - Enable native GOR token transfers (true/false)
- `NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS` - The Solana address that will receive token payments
- `NEXT_PUBLIC_ALNT_COST_LRN` - LRN for ALNT token pricing
- `NEXT_PUBLIC_DEPLOYMENT_COST_LRN` - LRN for deployment cost pricing
- `NEXT_PUBLIC_DOMAIN_SUFFIX` - Suffix to append to DNS names in the UI (e.g. ".example.com")
Server-side:
- `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 of account which will publish records
- The account should own the `laconic-deploy` authority which is set in `REGISTRY_AUTHORITY`
- `DEPLOYER_LRN` - The LRN of the deployer
## Run
- Deploy `gor-deploy` App:
```bash
# In gor-deploy/deploy dir
docker run -it \
-v ./:/app/deploy -w /app/deploy \
-e DEPLOYMENT_DNS=gor-deploy.laconic.com \
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: 5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2
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"

50
deploy/laconic-cli.sh Executable file
View File

@ -0,0 +1,50 @@
#!/bin/bash
# Laconic Registry CLI Docker wrapper script
# This script wraps the Docker command to run laconic registry CLI commands
# Run this script from the deploy directory
# Check if docker is available
if ! command -v docker &> /dev/null; then
echo "Error: Docker is not installed or not in PATH"
exit 1
fi
# Check if the cerc/laconic-registry-cli image exists
if ! docker image inspect cerc/laconic-registry-cli &> /dev/null; then
echo "Error: cerc/laconic-registry-cli Docker image not found"
echo "Please build the image first: docker build -t cerc/laconic-registry-cli ."
exit 1
fi
# Get current directory (should be deploy directory)
CURRENT_DIR="$(pwd)"
PROJECT_ROOT="$(dirname "$CURRENT_DIR")"
# Verify we're in the deploy directory
if [ ! -f "config.yml" ] || [ ! -f "laconic-cli.sh" ]; then
echo "Error: This script must be run from the deploy directory"
echo "Current directory: $CURRENT_DIR"
echo "Please cd to the deploy directory and run: ./laconic-cli.sh"
exit 1
fi
# Set up volume mounts
DEPLOY_MOUNT="-v $CURRENT_DIR:/app/deploy"
OUT_MOUNT=""
# Create out directory if it doesn't exist and always mount it
if [ ! -d "out" ]; then
mkdir -p "out"
fi
OUT_MOUNT="-v $CURRENT_DIR/out:/app/out"
# Run the Docker command with processed arguments
docker run --rm \
--add-host=host.docker.internal:host-gateway \
$DEPLOY_MOUNT \
$OUT_MOUNT \
-w /app/deploy \
cerc/laconic-registry-cli \
laconic registry -c config.yml \
"$@"

147
deploy/publish-pricing.md Normal file
View File

@ -0,0 +1,147 @@
# publish-pricing
## Setup
- Clone the repo:
```bash
git clone git@git.vdb.to:LaconicNetwork/gor-deploy.git
cd gor-deploy/deploy
```
- Build the Docker container:
```bash
docker build -t cerc/laconic-registry-cli .
# Builds image cerc/laconic-registry-cli:latest
```
- Configure `userKey` in the [registry CLI config](./config.yml):
NOTE: The `laconic` authority is required to set the published record names so the user key should be of the account that owns the `laconic` authority (owner account address: `laconic13maulvmjxnyx3g855vk0lsv5aptf3rpxskynef`). If you don't have an authority check [steps to reserve a new authority](#reserve-a-new-authority-optional)
```bash
nano config.yml
```
## Publish Record and Set Record Name
Publishing record requires a bond with enough funds as rent is taken for each record from the bond every year
The rent amount taken is `1000000alnt` so maintaining a bond with about 10x the rent amount i.e `10000000alnt` is recommended
Make sure, the record names are under `laconic` authority
### Publish Record for Cost of alnt
- alnt price calculation (reference: <https://store.laconic.com>):
- Cost of 1 deployment is `12960` in terms of `alnt` or `5` in terms of `USD`
- Hence, cost of 1 `alnt` comes out be `0.000386 USD` rounded off to 6 decimals
- Publish the record:
```bash
./laconic-cli.sh record publish --filename records/alnt-pricing.yml --bond-id <bond-id>
```
- Get record info:
```bash
./laconic-cli.sh record get --id <record-id>
```
- Set record name for cost of alnt record:
```bash
./laconic-cli.sh name set lrn://laconic/pricing/alnt <record-id>
```
### Publish Record for Cost of Deployment
- Publish the record:
```bash
./laconic-cli.sh record publish --filename records/webapp-deployment-pricing.yml --bond-id <bond-id>
```
- Get record info:
```bash
./laconic-cli.sh record get --id <record-id>
```
- Set record name for cost of deployment record
```bash
./laconic-cli.sh name set lrn://laconic/pricing/webapp-deployment <record-id>
```
- Now you should be able to use these records in the app
- You can now continue with [steps to deploy the app](./README.md)
## Create Bond (optional)
- Create bond:
```bash
./laconic-cli.sh bond create --type alnt --quantity 10000000
```
- Get bond info:
```bash
./laconic-cli.sh bond get --id <bond-id>
```
## Reserve a New Authority (optional)
Below steps are used to reserve `laconic` authority
- Reserve authority:
```bash
./laconic-cli.sh authority reserve laconic
```
- After reserving authority, commit phase begins which lasts for 1 minute so please commit the bid following below steps within that time period
- Check authority info:
```bash
./laconic-cli.sh authority whois laconic
```
- Note down auction ID from authority info as it is required in next steps
- Get auction info:
```bash
./laconic-cli.sh auction get <auction-id>
```
- Commit an auction bid:
```bash
# 5000000 alnt is the minimum bid amount for authority auction
./laconic-cli.sh auction bid commit <auction-id> 5000000 alnt
# Example file path inside container
Reveal file: /app/deploy/out/bafyreiay2rccax64yn4ljhvzvm3jkbebvzheyucuma5jlbpzpzd5i5gjuy.json
```
- The reveal phase starts as soon as commit phase ends and lasts for 1 minute so please reveal the bid within this time period
- Reveal bid:
```bash
./laconic-cli.sh auction bid reveal <auction-id> /app/deploy/out/<reaveal-file>.json
```
- Set authority bond after winning auction as it is required to use the published authority:
```bash
./laconic-cli.sh authority bond set laconic <bond-id>
```

0
deploy/records/.gitkeep Normal file
View File

View File

@ -0,0 +1,6 @@
record:
type: PricingRecord
for: "alnt"
amount: "0.000386"
currency: "USD"
version: 1.0.0

View File

@ -0,0 +1,8 @@
record:
type: ApplicationRecord
version: 0.0.4
repository_ref: bc0d10d4c38d2d20de88ac8352ab8571bbbffef4
repository: ["https://git.vdb.to/LaconicNetwork/gor-deploy"]
app_type: webapp
name: gor-deploy
app_version: 0.1.0

View File

@ -0,0 +1,6 @@
record:
type: PricingRecord
for: "webapp-deployment"
amount: "12960"
currency: "alnt"
version: 1.0.0

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"

View File

@ -11,6 +11,19 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}],
"@typescript-eslint/no-empty-object-type": "off",
"react-hooks/exhaustive-deps": "warn",
"indent": ["error", 2, { "SwitchCase": 1 }],
"react/jsx-indent": ["error", 2],
"react/jsx-indent-props": ["error", 2]
}
}
];
export default eslintConfig;

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 !!

5203
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,27 @@
{
"name": "atom-deploy",
"name": "gor-deploy",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"laconic": "laconic registry -c deploy/config.yml"
},
"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/wallet-adapter-backpack": "^0.1.9",
"@solana/wallet-adapter-base": "^0.9.18",
"@solana/wallet-adapter-phantom": "^0.9.28",
"@solana/wallet-adapter-react": "^0.15.20",
"@solana/wallet-adapter-react-ui": "^0.9.18",
"@solana/wallet-adapter-solflare": "^0.6.32",
"@solana/web3.js": "^1.98.2",
"axios": "^1.6.8",
"bn.js": "^5.2.2",
"next": "15.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
@ -20,10 +29,13 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@cerc-io/laconic-registry-cli": "^0.2.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.31.0",
"eslint-config-next": "15.3.1",
"tailwindcss": "^4",
"typescript": "^5"

View File

@ -1,135 +1,49 @@
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 { Connection } from '@solana/web3.js';
import { verifyUnusedSolanaPayment } from '@/utils/solana-verify';
import { transferLNTTokens } from '@/services/laconic-transfer';
import { getRegistry, getRegistryConfig } from '@/config';
import { getRequiredTokenInfo, RequiredTokenInfo } from '@/services/jupiter-price';
import { SOLANA_GOR_MINT_ADDRESS } from '@/constants/payments';
import { PaymentMethod } from '@/types';
import { getCostOfDeployment } from '@/services/registry';
import { IS_NAT_GOR_TRANSFER_ENABLED } from '@/utils/gorbagana';
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
assert(!IS_NAT_GOR_TRANSFER_ENABLED || process.env.NEXT_PUBLIC_GORBAGANA_RPC_URL, 'GORBAGANA_RPC_URL is required when NAT GOR transfer is enabled');
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
const GORBAGANA_RPC_URL = process.env.NEXT_PUBLIC_GORBAGANA_RPC_URL;
// Allow 20% slippage due to price fluctuations
const ALLOWED_SLIPPAGE_FACTOR = 0.2
// Use CAIP convention for chain ID: namespace + reference
const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // Solana mainnet
// Sleep helper function
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// ATOM payment verification function
const verifyAtomPayment = async (txHash: string): Promise<{
valid: boolean,
reason?: string,
amount?: string,
sender?: string
}> => {
try {
const apiEndpoint = process.env.NEXT_PUBLIC_COSMOS_API_URL;
const recipientAddress = process.env.NEXT_PUBLIC_RECIPIENT_ADDRESS;
const minPaymentUAtom = '100000'; // 0.1 ATOM in uatom
if (!apiEndpoint) {
return {
valid: false,
reason: 'ATOM API endpoint not configured'
};
}
if (!recipientAddress) {
return {
valid: false,
reason: 'ATOM recipient address not configured'
};
}
// Fetch transaction from the ATOM API endpoint
const response = await axios.get(`${apiEndpoint}/cosmos/tx/v1beta1/txs/${txHash}`);
if (!response.data || !response.data.tx || !response.data.tx_response) {
return {
valid: false,
reason: 'Invalid transaction data from API endpoint'
};
}
// Check if transaction was successful
const txResponse = response.data.tx_response;
if (txResponse.code !== 0) {
return {
valid: false,
reason: `Transaction failed with code ${txResponse.code}: ${txResponse.raw_log}`
};
}
// Check transaction timestamp (5-minute window)
const txTimestamp = new Date(txResponse.timestamp);
const now = new Date();
const timeDiffMs = now.getTime() - txTimestamp.getTime();
const timeWindowMs = 5 * 60 * 1000; // 5 minutes
if (timeDiffMs > timeWindowMs) {
return {
valid: false,
reason: `Transaction is older than 5 minutes (${Math.round(timeDiffMs / 60000)} minutes old)`
};
}
// Extract the payment details
const tx = response.data.tx;
let foundValidPayment = false;
let paymentAmountUAtom = '';
let sender = '';
// Get the sender address from the first signer
if (tx.auth_info && tx.auth_info.signer_infos && tx.auth_info.signer_infos.length > 0) {
sender = tx.auth_info.signer_infos[0].public_key.address || '';
}
// Find the send message in the transaction
for (const msg of tx.body.messages) {
if (msg['@type'] === '/cosmos.bank.v1beta1.MsgSend') {
if (msg.to_address === recipientAddress) {
for (const coin of msg.amount) {
if (coin.denom === 'uatom') {
// Get the amount in uatom
paymentAmountUAtom = coin.amount;
if (parseInt(paymentAmountUAtom) >= parseInt(minPaymentUAtom)) {
foundValidPayment = true;
}
break;
}
}
}
}
}
if (!foundValidPayment) {
return {
valid: false,
reason: `Payment amount (${paymentAmountUAtom || '0'}uatom) is less than required (${minPaymentUAtom}uatom) or not sent to the correct address (${recipientAddress})`
};
}
return {
valid: true,
amount: `${paymentAmountUAtom}uatom`,
sender
};
} catch (error) {
console.error('Error verifying ATOM payment:', error);
return {
valid: false,
reason: `Failed to verify transaction: ${error.message || 'Unknown error'}`
};
}
};
// Extract repo name from URL
const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, provider: string } => {
try {
const parsedUrl = new URL(url);
const pathParts = parsedUrl.pathname.split('/').filter(part => part);
// GitHub repository URL pattern
if (parsedUrl.hostname === 'github.com' && pathParts.length >= 2) {
return {
repoName: pathParts[1],
repoName: pathParts[1],
repoUrl: `https://github.com/${pathParts[0]}/${pathParts[1]}`,
provider: 'github'
};
}
// GitLab repository URL pattern
if ((parsedUrl.hostname === 'gitlab.com' || parsedUrl.hostname.includes('gitlab')) && pathParts.length >= 2) {
return {
@ -138,7 +52,7 @@ const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, prov
provider: 'gitlab'
};
}
// Bitbucket repository URL pattern
if (parsedUrl.hostname === 'bitbucket.org' && pathParts.length >= 2) {
return {
@ -147,7 +61,7 @@ const extractRepoInfo = (url: string): { repoName: string, repoUrl: string, prov
provider: 'bitbucket'
};
}
// For other URLs, try to extract a meaningful name from the hostname
const hostnameWithoutTLD = parsedUrl.hostname.split('.')[0];
return {
@ -175,7 +89,7 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
if (match) {
const [, owner, repo] = match;
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/main`;
const response = await axios.get(apiUrl);
if (response.data && response.data.sha) {
// Return both full hash and short hash (7 characters)
@ -186,7 +100,7 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
}
}
}
// For non-GitHub repositories or if fetching fails, return a default value
return {
fullHash: 'main',
@ -202,202 +116,266 @@ const fetchLatestCommitHash = async (repoUrl: string, provider: string): Promise
};
// Registry transaction retry helper
const registryTransactionWithRetry = async (
export const registryTransactionWithRetry = async (
txFn: () => Promise<unknown>,
maxRetries = 3,
delay = 1000
): Promise<unknown> => {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await txFn();
} catch (error) {
console.error(`Transaction attempt ${attempt + 1} failed:`, error);
lastError = error;
if (attempt < maxRetries - 1) {
await sleep(delay);
}
}
}
throw lastError;
};
let solanaConnection: Connection | null = null;
let gorbaganaConnection: Connection | null = null;
// Helper function to get the appropriate connection based on payment method
const getConnection = (paymentMethod: PaymentMethod): Connection => {
switch (paymentMethod) {
case PaymentMethod.NAT_GOR:
if (!gorbaganaConnection) {
gorbaganaConnection = new Connection(GORBAGANA_RPC_URL!);
}
return gorbaganaConnection;
case PaymentMethod.SPL_TOKEN:
if (!solanaConnection) {
solanaConnection = new Connection(SOLANA_RPC_URL);
}
return solanaConnection;
default:
throw new Error("Invalid payment method");
}
};
export async function POST(request: NextRequest) {
try {
// First check if the request body is valid JSON
let url, txHash;
let url, txHash, senderPublicKey, paymentMethod;
let connection: Connection;
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'
paymentMethod = body.paymentMethod as PaymentMethod;
// Get the appropriate connection based on payment method
connection = getConnection(paymentMethod);
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 || !paymentMethod) {
return NextResponse.json({
status: 'error',
message: 'Missing required fields: url, txHash, and paymentMethod are required'
}, { status: 400 });
}
// Validate payment method
if (paymentMethod !== PaymentMethod.NAT_GOR && paymentMethod !== PaymentMethod.SPL_TOKEN) {
return NextResponse.json({
status: 'error',
message: 'Invalid payment method. Must be "nat-gor" or "spl-token".'
}, { status: 400 });
}
} catch (error) {
return NextResponse.json({
status: 'error',
message: 'Invalid JSON in request body'
}, { status: 400 });
}
// First, verify the ATOM payment before doing anything else
console.log('Step 0: Verifying ATOM payment...');
const paymentVerificationResult = await verifyAtomPayment(txHash);
if (!paymentVerificationResult.valid) {
console.error('ATOM payment verification failed:', paymentVerificationResult.reason);
console.error(error);
return NextResponse.json({
status: 'error',
message: `Payment verification failed: ${paymentVerificationResult.reason}`
message: 'Invalid JSON in request body'
}, { status: 400 });
}
console.log('ATOM payment verified successfully:', {
amount: paymentVerificationResult.amount,
sender: paymentVerificationResult.sender
// Verify Solana payment
console.log('Step 0: Verifying Solana token payment...');
let requiredTokenInfo: RequiredTokenInfo;
const targetUsdAmount = await getCostOfDeployment();
const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!;
try {
if (paymentMethod === PaymentMethod.NAT_GOR) {
requiredTokenInfo = await getRequiredTokenInfo(targetUsdAmount, SOLANA_GOR_MINT_ADDRESS);
} else if (paymentMethod === PaymentMethod.SPL_TOKEN) {
requiredTokenInfo = await getRequiredTokenInfo(targetUsdAmount, mintAddress);
} else {
return NextResponse.json({
status: 'error',
message: 'Unsupported payment method'
}, { status: 400 });
}
} catch (error) {
console.error('Error calculating expected amount:', error);
return NextResponse.json({
status: 'error',
message: 'Unable to verify payment due to price calculation error'
}, { status: 500 });
}
const requiredAmountInBaseUnits = requiredTokenInfo.requiredAmountInBaseUnits;
const expectedTokenAmount = Math.round(requiredAmountInBaseUnits - ALLOWED_SLIPPAGE_FACTOR * requiredAmountInBaseUnits);
const solanaPaymentResult = await verifyUnusedSolanaPayment(
connection,
txHash,
new BN(expectedTokenAmount),
paymentMethod,
);
if (!solanaPaymentResult.valid) {
console.error(`Solana ${paymentMethod} payment verification failed:`, solanaPaymentResult.reason);
return NextResponse.json({
status: 'error',
message: `Payment verification failed: ${solanaPaymentResult.reason}`
}, { status: 400 });
}
console.log(`Solana ${paymentMethod} 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 laconicTxHash = 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',
'NEXT_PUBLIC_REGISTRY_CHAIN_ID',
'NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT',
'NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT',
'REGISTRY_BOND_ID',
'REGISTRY_AUTHORITY',
'REGISTRY_USER_KEY',
'REGISTRY_USER_KEY', // This is the same as the prefilled account for LNT transfers
'DEPLOYER_LRN',
'NEXT_PUBLIC_RECIPIENT_ADDRESS',
'NEXT_PUBLIC_COSMOS_API_URL'
'NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS',
'NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS'
];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error(`Missing environment variable: ${envVar}`);
return NextResponse.json({
status: 'error',
message: `Server configuration error: Missing environment variable: ${envVar}`
return NextResponse.json({
status: 'error',
message: `Server configuration error: Missing environment variable: ${envVar}`
}, { status: 500 });
}
}
// Extract repository information from URL
const { repoName, repoUrl, provider } = extractRepoInfo(url);
console.log(`Extracted repo info - Name: ${repoName}, URL: ${repoUrl}, Provider: ${provider}`);
// Fetch latest commit hash (or default to 'main' if unable to fetch)
const { fullHash, shortHash } = await fetchLatestCommitHash(repoUrl, provider);
console.log(`Using commit hash - Full: ${fullHash}, Short: ${shortHash}`);
// Use the repository name as the app name
const appName = repoName;
console.log(`Using app name: ${appName}`);
// Sanitize the app name to ensure it's DNS-compatible (only alphanumeric and dashes)
const sanitizedAppName = appName.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
// Generate a random salt (6 alphanumeric characters) to prevent name collisions
const generateSalt = (): string => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
return Array.from({ length: 6 }, () => chars.charAt(Math.floor(Math.random() * chars.length))).join('');
};
const salt = generateSalt();
console.log(`Generated salt: ${salt}`);
// Create DNS name in format: app_name-shortcommithash-salt
const dnsName = `${sanitizedAppName}-${shortHash}-${salt}`;
console.log(`DNS name with salt: ${dnsName} (sanitized from: ${appName})`);
// Create DNS name in format: app_name-shortcommithash
const dnsName = `${sanitizedAppName}-${shortHash}`;
console.log(`DNS name: ${dnsName} (sanitized from: ${appName})`);
// Ensure the DNS name doesn't have consecutive dashes or start/end with a dash
let cleanDnsName = dnsName
.replace(/--+/g, '-') // Replace consecutive dashes with a single dash
.replace(/^-+|-+$/g, ''); // Remove leading and trailing dashes
// Ensure DNS name is valid (63 chars max per label, all lowercase, starts with a letter)
if (cleanDnsName.length > 63) {
// If too long, truncate but preserve both the commit hash and salt parts
const suffixPart = `-${shortHash}-${salt}`;
const maxAppNameLength = 63 - suffixPart.length;
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + suffixPart;
// If too long, truncate but preserve both the commit hash
const maxAppNameLength = 63 - shortHash.length;
cleanDnsName = sanitizedAppName.substring(0, maxAppNameLength) + shortHash;
}
// If the DNS name ended up empty (unlikely) or doesn't start with a letter (possible),
// add a prefix to make it valid
if (!cleanDnsName || !/^[a-z]/.test(cleanDnsName)) {
cleanDnsName = `app-${cleanDnsName}`;
}
console.log(`Final DNS name with salt: ${cleanDnsName}`);
console.log(`Final DNS name: ${cleanDnsName}`);
// Set up Registry config
const config = {
chainId: process.env.REGISTRY_CHAIN_ID!,
rpcEndpoint: process.env.REGISTRY_RPC_ENDPOINT!,
gqlEndpoint: process.env.REGISTRY_GQL_ENDPOINT!,
bondId: process.env.REGISTRY_BOND_ID!,
authority: process.env.REGISTRY_AUTHORITY!,
privateKey: process.env.REGISTRY_USER_KEY!,
fee: {
gas: process.env.REGISTRY_GAS || '900000',
fees: process.env.REGISTRY_FEES || '900000alnt',
gasPrice: '0.001alnt', // Hardcoded valid gas price string with denomination
},
};
const config = getRegistryConfig()
console.log('Registry config:', {
...config,
privateKey: '[REDACTED]', // Don't log the private key
});
const deployerLrn = process.env.DEPLOYER_LRN!;
// Create Registry client instance
const gasPrice = GasPrice.fromString('0.001alnt');
console.log('Using manual gas price:', gasPrice);
const registry = new Registry(
config.gqlEndpoint,
config.rpcEndpoint,
{ chainId: config.chainId, gasPrice }
);
// Create LRN for the application with commit hash and salt
// We already have the salt from earlier, so we use it directly
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}-${salt}`;
const registry = getRegistry()
// Create LRN for the application with commit hash
const lrn = `lrn://${config.authority}/applications/${appName}-${shortHash}`;
// Get current timestamp for the meta note
const timestamp = new Date().toUTCString();
// Step 1: Create and publish ApplicationRecord first
console.log('Step 1: Publishing ApplicationRecord...');
const applicationRecord = {
type: 'ApplicationRecord',
name: `${appName}-${shortHash}-${salt}`, // Include commit hash and salt in the record name
name: `${appName}-${shortHash}`, // Include commit hash in the record name
version: '1.0.0',
app_type: 'webapp',
repository: [repoUrl],
repository_ref: fullHash,
app_version: '0.0.1'
};
// Create fee for transaction directly
const fee = {
amount: [{ denom: 'alnt', amount: process.env.REGISTRY_FEES?.replace('alnt', '') || '900000' }],
gas: process.env.REGISTRY_GAS || '900000',
};
console.log('Application record data:', applicationRecord);
// Publish the application record
let applicationRecordId;
try {
const appRecordResult = await registryTransactionWithRetry(() =>
const appRecordResult = await registryTransactionWithRetry(() =>
registry.setRecord(
{
privateKey: config.privateKey,
@ -405,13 +383,12 @@ export async function POST(request: NextRequest) {
bondId: config.bondId,
},
config.privateKey,
fee
)
) as { id?: string };
applicationRecordId = appRecordResult.id;
console.log('Application record published with ID:', applicationRecordId);
if (!applicationRecordId) {
return NextResponse.json({
status: 'error',
@ -425,7 +402,7 @@ export async function POST(request: NextRequest) {
message: err instanceof Error ? err.message : 'Unknown error publishing ApplicationRecord'
}, { status: 500 });
}
// Step 2: Set name mappings
console.log('Step 2: Setting name mappings...');
try {
@ -437,11 +414,10 @@ export async function POST(request: NextRequest) {
lrn
},
config.privateKey,
fee
)
);
console.log(`Set name mapping: ${lrn} -> ${applicationRecordId}`);
// Set the versioned LRN (with repository_ref)
await registryTransactionWithRetry(() =>
registry.setName(
@ -450,7 +426,6 @@ export async function POST(request: NextRequest) {
lrn: `${lrn}@${fullHash}`
},
config.privateKey,
fee
)
);
console.log(`Set name mapping: ${lrn}@${fullHash} -> ${applicationRecordId}`);
@ -461,36 +436,40 @@ export async function POST(request: NextRequest) {
message: err instanceof Error ? err.message : 'Unknown error setting name mappings'
}, { status: 500 });
}
// Step 3: Create ApplicationDeploymentRequest
console.log('Step 3: Creating ApplicationDeploymentRequest...');
// Prepare record data for deployment request
const deploymentRequestData = {
type: 'ApplicationDeploymentRequest',
version: '1.0.0',
name: `${appName}-${shortHash}-${salt}`, // Update name to match application record
application: lrn, // LRN already includes commit hash and salt
name: `${appName}-${shortHash}`, // Update name to match application record
application: lrn, // LRN already includes commit hash
deployer: deployerLrn,
dns: cleanDnsName,
config: {
env: {
LACONIC_HOSTED_CONFIG_laconicd_chain_id: process.env.REGISTRY_CHAIN_ID || 'laconic-testnet-2'
}
// TODO: Add feature to set env config for app
env: {}
},
meta: {
note: `Added via ATOM-Deploy @ ${timestamp}`,
note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`,
repository: repoUrl,
repository_ref: fullHash,
},
payment: txHash,
external_payment: {
chain_id: SOLANA_CHAIN_ID,
tx_hash: txHash,
pubkey: senderPublicKey
},
payment: laconicTxHash,
};
console.log('Deployment request data:', deploymentRequestData);
// Publish the deployment request
let deploymentRequestId;
try {
const deployRequestResult = await registryTransactionWithRetry(() =>
const deployRequestResult = await registryTransactionWithRetry(() =>
registry.setRecord(
{
privateKey: config.privateKey,
@ -498,13 +477,12 @@ export async function POST(request: NextRequest) {
bondId: config.bondId,
},
config.privateKey,
fee
)
) as { id?: string };
deploymentRequestId = deployRequestResult.id;
console.log('Deployment request published with ID:', deploymentRequestId);
if (!deploymentRequestId) {
return NextResponse.json({
status: 'error',
@ -518,7 +496,7 @@ export async function POST(request: NextRequest) {
message: err instanceof Error ? err.message : 'Unknown error publishing deployment request'
}, { status: 500 });
}
// Return combined results
return NextResponse.json({
id: deploymentRequestId,

View File

@ -1,7 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper";
import WalletProviders from "../components/WalletProviders";
import { PaymentMethodProvider } from "../contexts/PaymentMethodContext";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -14,8 +18,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({
@ -29,7 +33,11 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ErrorBoundaryWrapper />
{children}
<PaymentMethodProvider>
<WalletProviders>
{children}
</WalletProviders>
</PaymentMethodProvider>
</body>
</html>
);

View File

@ -1,17 +1,44 @@
'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, useEffect, useState, useRef, useMemo } from 'react';
import dynamic from 'next/dynamic';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { useWallet } from '@solana/wallet-adapter-react';
import { BackpackWalletName } from '@solana/wallet-adapter-backpack';
import URLForm from '@/components/URLForm';
import StatusDisplay from '@/components/StatusDisplay';
import { createApplicationDeploymentRequest } from '@/services/registry';
import { PAYMENT_METHOD_LABELS } from '@/constants/payments';
import { usePaymentMethod } from '@/contexts/PaymentMethodContext';
import { PaymentMethod } from '@/types';
import { IS_NAT_GOR_TRANSFER_ENABLED } from '@/utils/gorbagana';
// Dynamically import components to avoid SSR issues with browser APIs
const PaymentModal = dynamic(() => import('@/components/PaymentModal'), { ssr: false });
// Use following curl request to get Gorbagana chain genesis hash:
// curl https://rpc.gorbagana.wtf \
// -X POST \
// -H "Content-Type: application/json" \
// --data '{"jsonrpc":"2.0","id":1,"method":"getGenesisHash"}'
//
// RPC endpoint reference: https://docs.gorbagana.wtf/testnet-v2-devnet.html
const GORBAGANA_GENESIS_HASH = '533uBE9RRquhTBqEX58oV52FdTTsReMdAvaUvP6hNjsn';
// Use following curl request to get Solana chain genesis hash:
// curl https://api.mainnet-beta.solana.com \
// -X POST \
// -H "Content-Type: application/json" \
// --data '{"jsonrpc":"2.0","id":1,"method":"getGenesisHash"}'
// RPC endpoint reference: https://solana.com/docs/references/clusters#on-a-high-level
const SOLANA_GENESIS_HASH = '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d';
export default function Home() {
const [walletAddress, setWalletAddress] = useState<string | null>(null);
const { wallet, connected, publicKey, disconnect } = useWallet();
const { selectedPaymentMethod, setSelectedPaymentMethod } = usePaymentMethod();
const [url, setUrl] = useState<string | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [status, setStatus] = useState<'idle' | 'creating' | 'success' | 'error'>('idle');
@ -22,63 +49,127 @@ export default function Home() {
const [dns, setDns] = useState<string | null>(null);
const [appName, setAppName] = useState<string | null>(null);
const [repoUrl, setRepoUrl] = useState<string | null>(null);
const [commitHash, setCommitHash] = useState<string | null>(null);
const [shortCommitHash, setShortCommitHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [incorrectChainWarning, setIncorrectChainWarning] = useState<string | null>(null);
const [isFetchingChainGenesisHash, setIsFetchingChainGenesisHash] = useState(false);
const handleConnect = (address: string) => {
setWalletAddress(address);
};
useEffect(() => {
if (!IS_NAT_GOR_TRANSFER_ENABLED) {
setSelectedPaymentMethod(PaymentMethod.SPL_TOKEN);
}
}, [setSelectedPaymentMethod]);
useEffect(() => {
setIncorrectChainWarning(null);
if (!wallet || wallet.adapter.name !== BackpackWalletName || !connected || !selectedPaymentMethod) {
return;
}
const warnOnIncorrectChain = async () => {
setIsFetchingChainGenesisHash(true);
try {
// @ts-expect-error: backpack exists on window object
const genesisHash = await window.backpack.solana.connection.getGenesisHash();
const expectedGenesisHash = selectedPaymentMethod === PaymentMethod.NAT_GOR
? GORBAGANA_GENESIS_HASH
: SOLANA_GENESIS_HASH;
const expectedChainName = selectedPaymentMethod === PaymentMethod.NAT_GOR
? "Gorbagana"
: "Solana";
if (genesisHash !== expectedGenesisHash) {
setIncorrectChainWarning(
`Unsupported network selected in wallet. Please switch to network for ${expectedChainName} chain and reconnect the wallet.`
);
}
} finally {
setIsFetchingChainGenesisHash(false);
}
}
warnOnIncorrectChain();
}, [wallet, selectedPaymentMethod, connected]);
// Track previous payment method to detect switches
const previousPaymentMethodRef = useRef<PaymentMethod | null>(null);
useEffect(() => {
if (selectedPaymentMethod === null) {
return;
}
// Only disconnect if switching between different payment methods while connected
if (previousPaymentMethodRef.current !== null &&
previousPaymentMethodRef.current !== selectedPaymentMethod &&
connected) {
disconnect();
}
previousPaymentMethodRef.current = selectedPaymentMethod;
}, [selectedPaymentMethod, connected, disconnect]);
const handleUrlSubmit = (submittedUrl: string) => {
setUrl(submittedUrl);
setShowPaymentModal(true);
};
const handlePaymentComplete = async (hash: string) => {
// Helper function to check if current wallet is compatible with selected payment method
const isWalletCompatible = useMemo(() => {
if (!selectedPaymentMethod || !wallet) return false;
const walletName = wallet.adapter.name.toLowerCase();
const isBackpack = walletName.includes('backpack');
if (selectedPaymentMethod === PaymentMethod.NAT_GOR) {
return isBackpack; // Only Backpack for native GOR
} else {
return true; // Only non-Backpack wallets for SPL tokens
}
}, [selectedPaymentMethod, wallet]);
const handlePaymentComplete = useCallback(async (hash: string, paymentMethod: PaymentMethod) => {
if (!publicKey || !url) {
return
}
setTxHash(hash);
setShowPaymentModal(false);
setStatus('creating');
try {
// Create the Laconic Registry record (payment verification is done in the API)
if (url) {
const result = await createApplicationDeploymentRequest(url, hash);
if (result.status === 'success') {
setRecordId(result.id);
if (result.applicationRecordId) {
setAppRecordId(result.applicationRecordId);
}
if (result.lrn) {
setLrn(result.lrn);
}
if (result.dns) {
setDns(result.dns);
}
if (result.appName) {
setAppName(result.appName);
}
if (result.repoUrl) {
setRepoUrl(result.repoUrl);
}
if (result.commitHash) {
setCommitHash(result.commitHash);
}
if (result.shortCommitHash) {
setShortCommitHash(result.shortCommitHash);
}
setStatus('success');
} else {
setStatus('error');
setError(result.message || 'Failed to create record in Laconic Registry');
const result = await createApplicationDeploymentRequest(url, hash, paymentMethod);
if (result.status === 'success') {
setRecordId(result.id);
if (result.applicationRecordId) {
setAppRecordId(result.applicationRecordId);
}
if (result.lrn) {
setLrn(result.lrn);
}
if (result.dns) {
setDns(result.dns);
}
if (result.appName) {
setAppName(result.appName);
}
if (result.repoUrl) {
setRepoUrl(result.repoUrl);
}
setStatus('success');
} else {
setStatus('error');
setError(result.message || 'Failed to create record in Laconic Registry');
}
} catch (error) {
setStatus('error');
setError(error instanceof Error ? error.message : 'An unknown error occurred');
}
};
}, [publicKey, url])
const handleClosePaymentModal = () => {
setShowPaymentModal(false);
@ -86,59 +177,175 @@ export default function Home() {
return (
<main className="min-h-screen flex flex-col items-center justify-center p-6">
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }}
className="max-w-xl w-full p-8 rounded-xl shadow-lg border">
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }}
className="max-w-xl w-full p-8 rounded-xl shadow-lg border">
<h1 className="text-2xl font-bold mb-8 text-center" style={{ color: 'var(--foreground)' }}>
Deploy Frontends with ATOM and Laconic
Deploy Frontends with {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} + Laconic
</h1>
{/* Step 1: Payment Method Selection */}
{ IS_NAT_GOR_TRANSFER_ENABLED &&
<div className="mb-10 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}>
<h2 className="text-lg font-semibold mb-4 flex items-center">
<span className="inline-flex items-center justify-center mr-3 w-7 h-7 rounded-full text-sm font-bold"
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>1</span>
Connect Your Wallet
Choose Payment Method
</h2>
<KeplrConnect onConnect={handleConnect} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => setSelectedPaymentMethod(PaymentMethod.NAT_GOR)}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
}`}
style={{
backgroundColor: selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'var(--accent)' : 'var(--card-bg)',
borderColor: selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'var(--primary)' : 'var(--card-border)'
}}
>
<div className="text-left">
<h3 className="font-semibold text-lg mb-2">GOR (native)</h3>
<p className="text-xs mt-1" style={{ color: 'var(--muted-foreground)' }}>
Compatible with: Backpack
</p>
</div>
</button>
<button
onClick={() => setSelectedPaymentMethod(PaymentMethod.SPL_TOKEN)}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedPaymentMethod === PaymentMethod.SPL_TOKEN ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
}`}
style={{
backgroundColor: selectedPaymentMethod === PaymentMethod.SPL_TOKEN ? 'var(--accent)' : 'var(--card-bg)',
borderColor: selectedPaymentMethod === PaymentMethod.SPL_TOKEN ? 'var(--primary)' : 'var(--card-border)'
}}
>
<div className="text-left">
<h3 className="font-semibold text-lg mb-2">{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Token</h3>
<p className="text-xs mt-1" style={{ color: 'var(--muted-foreground)' }}>
Compatible with: All Solana compatible wallets
</p>
</div>
</button>
</div>
</div>
<div className="mb-8 p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)', opacity: walletAddress ? '1' : '0.6' }}>
}
{/* Step 2: Wallet Connection */}
<div className="mb-10 p-6 rounded-lg" style={{
background: 'var(--muted-light)',
borderLeft: '4px solid var(--primary)',
opacity: selectedPaymentMethod ? '1' : '0.6'
}}>
<h2 className="text-lg font-semibold mb-4 flex items-center">
Connect Compatible Wallet
</h2>
{!selectedPaymentMethod ? (
<p className="text-center" style={{ color: 'var(--muted-foreground)' }}>
Please select a payment method first
</p>
) : (
<div className="text-center">
<p className="mb-4" style={{ color: 'var(--muted-foreground)' }}>
Selected: <span className="font-semibold" style={{ color: 'var(--foreground)' }}>
{PAYMENT_METHOD_LABELS[selectedPaymentMethod]}
</span>
</p>
{connected && publicKey ? (
<div className="flex flex-col items-center space-y-3">
<div className="flex items-center">
<span className="w-3 h-3 rounded-full mr-2" style={{
backgroundColor: isWalletCompatible ? 'var(--success)' : 'var(--destructive)'
}}></span>
<p className="font-medium" style={{
color: isWalletCompatible ? 'var(--success)' : 'var(--destructive)'
}}>
{isWalletCompatible ? 'Compatible' : 'Incompatible'} Wallet ({wallet?.adapter.name})
</p>
</div>
{!isWalletCompatible && (
<p className="text-sm text-amber-400">
This wallet is not compatible with {PAYMENT_METHOD_LABELS[selectedPaymentMethod]} payments.
Please select a different wallet or payment method.
</p>
)}
{ isFetchingChainGenesisHash && (
<p className="text-sm text-muted-foreground">
Checking wallet network compatibility...
</p>
)}
{incorrectChainWarning && (
<p className="text-sm text-amber-400">
{incorrectChainWarning}
</p>
)}
<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">{publicKey.toBase58()}</p>
</div>
<WalletMultiButton
className="!px-4 !py-2 !rounded-md !text-sm !transition-colors"
style={{
backgroundColor: 'var(--muted)',
color: 'var(--foreground)',
border: '1px solid var(--input-border)'
}}
/>
</div>
) : (
<div>
<p className="mb-4 text-sm" style={{ color: 'var(--muted-foreground)' }}>
{selectedPaymentMethod === PaymentMethod.NAT_GOR
? 'Only Backpack wallet supports native GOR payments'
: 'Phantom and Solflare wallets support SPL token payments'
}
</p>
<WalletMultiButton
className="!px-6 !py-3 !rounded-md !w-full !transition-colors"
style={{
backgroundColor: 'var(--primary)',
color: 'var(--primary-foreground)',
}}
/>
</div>
)}
</div>
)}
</div>
{/* Step 3: URL Input */}
<div className="mb-8 p-6 rounded-lg" style={{
background: 'var(--muted-light)',
borderLeft: '4px solid var(--primary)',
opacity: (connected && isWalletCompatible) ? '1' : '0.6'
}}>
<h2 className="text-lg font-semibold mb-4 flex items-center">
<span className="inline-flex items-center justify-center mr-3 w-7 h-7 rounded-full text-sm font-bold"
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>2</span>
Enter URL to Deploy
</h2>
<URLForm
onSubmit={handleUrlSubmit}
disabled={!walletAddress || status === 'creating'}
<URLForm
onSubmit={handleUrlSubmit}
disabled={!connected || !isWalletCompatible || isFetchingChainGenesisHash || status === 'creating' || Boolean(incorrectChainWarning)}
/>
</div>
{status !== 'idle' && (
<div className="p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}>
<h2 className="text-lg font-semibold mb-4 flex items-center">
<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)' }}>3</span>
Deployment Status
</h2>
<StatusDisplay
status={status}
txHash={txHash || undefined}
recordId={recordId || undefined}
<StatusDisplay
status={status}
txHash={txHash || undefined}
recordId={recordId || undefined}
appRecordId={appRecordId || undefined}
lrn={lrn || undefined}
dns={dns || undefined}
appName={appName || undefined}
repoUrl={repoUrl || undefined}
commitHash={commitHash || undefined}
shortCommitHash={shortCommitHash || undefined}
error={error || undefined}
error={error || undefined}
/>
</div>
)}
</div>
{showPaymentModal && walletAddress && url && (
<PaymentModal
{showPaymentModal && url && connected && publicKey && selectedPaymentMethod && (
<PaymentModal
isOpen={showPaymentModal}
onClose={handleClosePaymentModal}
url={url}

View File

@ -18,7 +18,7 @@ export default function ErrorBoundary() {
nextjs-portal {
display: none !important;
}
/* Specifically target the error popup button */
[data-nextjs-dialog-overlay],
[data-nextjs-toast],
@ -35,4 +35,4 @@ export default function ErrorBoundary() {
}, []);
return null;
}
}

View File

@ -7,4 +7,4 @@ const ErrorBoundary = dynamic(() => import('./ErrorBoundary'), { ssr: false });
export default function ErrorBoundaryWrapper() {
return <ErrorBoundary />;
}
}

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,14 +1,22 @@
'use client';
import { useState } from 'react';
import { sendAtomPayment } from '@/services/keplr';
import { useCallback, useState, useEffect } from 'react';
import assert from 'assert';
interface PaymentModalProps {
isOpen: boolean;
onClose: () => void;
url: string;
onPaymentComplete: (txHash: string) => void;
}
import { Connection } from '@solana/web3.js';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { sendSolanaPayment } from '@/services/solana';
import { getRequiredTokenInfo, RequiredTokenInfo } from '@/services/jupiter-price';
import { PaymentMethod, PaymentModalProps, PaymentRequest } from '@/types';
import { PAYMENT_METHOD_LABELS, SOLANA_GOR_MINT_ADDRESS } from '@/constants/payments';
import { usePaymentMethod } from '@/contexts/PaymentMethodContext';
import { getCostOfDeployment } from '@/services/registry';
import { IS_NAT_GOR_TRANSFER_ENABLED } from '@/utils/gorbagana';
assert(!IS_NAT_GOR_TRANSFER_ENABLED || process.env.NEXT_PUBLIC_GORBAGANA_RPC_URL, 'GORBAGANA_RPC_URL is required when NAT GOR transfer is enabled');
const GORBAGANA_RPC_URL = process.env.NEXT_PUBLIC_GORBAGANA_RPC_URL;
export default function PaymentModal({
isOpen,
@ -16,60 +24,138 @@ export default function PaymentModal({
url,
onPaymentComplete,
}: PaymentModalProps) {
const [amount, setAmount] = useState('0.01');
const { selectedPaymentMethod: paymentMethod } = usePaymentMethod();
const { connection: solanaConnection } = useConnection();
const { wallet, publicKey } = useWallet();
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 [tokenAmount, setTokenAmount] = useState<number>(0);
const [tokenDecimals, setTokenDecimals] = useState<number>(6); // Default fallback
const [loadingPrice, setLoadingPrice] = useState(true);
const [deploymentCost, setDeploymentCost] = useState<number | null>(null);
// 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('');
useEffect(() => {
const getDeploymentCostInfo = async () => {
const cost = await getCostOfDeployment();
setDeploymentCost(cost);
}
};
const handlePayment = async () => {
// Validate amount before sending
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
setError('Please enter a valid positive amount');
getDeploymentCostInfo();
}, []);
const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!;
const tokenSymbol = process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL;
// Fetch payment amount based on USD price for both payment methods
useEffect(() => {
if (!isOpen || !deploymentCost || !paymentMethod) return;
const fetchPaymentAmount = async () => {
setLoadingPrice(true);
setError('');
try {
let requiredTokenInfo: RequiredTokenInfo
if (paymentMethod === PaymentMethod.NAT_GOR) {
// Fetch native GOR amount using solana GOR token price
requiredTokenInfo = await getRequiredTokenInfo(deploymentCost, SOLANA_GOR_MINT_ADDRESS);
} else if (paymentMethod === PaymentMethod.SPL_TOKEN) {
// Fetch SPL token amount using token mint price
requiredTokenInfo = await getRequiredTokenInfo(deploymentCost, mintAddress);
} else {
setError('Invalid payment method');
return;
}
setTokenAmount(requiredTokenInfo.requiredAmountInBaseUnits);
setTokenDecimals(requiredTokenInfo.decimals);
} catch (error) {
console.error('Error fetching payment amount:', error);
setError('Unable to fetch current payment amount. Please try again.');
} finally {
setLoadingPrice(false);
}
};
fetchPaymentAmount();
}, [isOpen, paymentMethod, deploymentCost, mintAddress]);
// Initialize state when modal opens
useEffect(() => {
if (isOpen) {
setError('');
setTokenAmount(0);
}
}, [isOpen]);
const handlePayment = useCallback(async () => {
if (!paymentMethod) {
return;
}
if (tokenAmount === 0 || loadingPrice) {
setError('Payment amount not ready. Please wait.');
return;
}
if (!wallet || !wallet.adapter) {
setError('Wallet not connected.');
return;
}
setLoading(true);
setError('');
try {
const result = await sendAtomPayment(recipientAddress, amount);
if (result.status === 'success' && result.hash) {
onPaymentComplete(result.hash);
const paymentRequest: PaymentRequest = {
paymentMethod: paymentMethod,
amount: tokenAmount,
};
// Use different RPC connection based on payment method
const connectionToUse = paymentMethod === PaymentMethod.NAT_GOR ? new Connection(GORBAGANA_RPC_URL!) : solanaConnection;
const result = await sendSolanaPayment(
wallet.adapter,
connectionToUse,
publicKey!.toBase58(),
paymentRequest
);
if (result.success && result.transactionSignature) {
onPaymentComplete(result.transactionSignature, paymentMethod);
} else {
setError(result.message || 'Payment failed. Please try again.');
setError(result.error || 'Payment failed. Please try again.');
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Payment failed. Please try again.');
} finally {
setLoading(false);
}
}, [paymentMethod, tokenAmount, loadingPrice, wallet, solanaConnection, publicKey, onPaymentComplete]);
const getPaymentAmountDisplay = () => {
if (loadingPrice) {
return 'Loading...'
};
return tokenAmount > 0 ? `${(tokenAmount / Math.pow(10, tokenDecimals)).toFixed(6)}` : '0';
};
if (!isOpen) return null;
if (!isOpen || !paymentMethod) return null;
return (
<div className="fixed inset-0 flex items-center justify-center p-4 z-50" style={{ background: 'rgba(15, 23, 42, 0.75)' }}>
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
<div className="p-6 border-b" style={{ borderColor: 'var(--card-border)' }}>
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>Complete Payment</h2>
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>
Complete Payment
</h2>
</div>
<div className="p-6 space-y-6">
<div>
<p className="text-sm mb-2 font-medium" style={{ color: 'var(--muted)' }}>URL to be deployed:</p>
@ -77,51 +163,120 @@ export default function PaymentModal({
<code className="text-sm font-mono">{url}</code>
</div>
</div>
{/* Payment Method Display */}
<div>
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--foreground)' }}>
Payment Method
</label>
<div className="p-3 rounded-md" style={{ background: 'var(--muted-light)', color: 'var(--foreground)' }}>
<p className="text-sm font-semibold">
{PAYMENT_METHOD_LABELS[paymentMethod]}
</p>
</div>
</div>
{/* Payment Details */}
<div>
<p className="text-sm mb-2 font-medium" style={{ color: 'var(--muted)' }}>Recipient Address:</p>
<div className="p-3 rounded-md overflow-hidden" style={{ background: 'var(--muted-light)', color: 'var(--foreground)' }}>
<code className="text-sm font-mono break-all block">{recipientAddress}</code>
<code className="text-sm font-mono break-all block">{process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS}</code>
</div>
</div>
<div>
<label htmlFor="amount" className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
Amount (ATOM)
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
Payment Amount
</label>
<div className="relative">
<input
id="amount"
type="number"
min="0.01"
step="0.01"
value={amount}
onChange={handleAmountChange}
className="w-full p-3 pr-12 rounded-md"
style={{
background: 'var(--card-bg)',
border: '1px solid var(--input-border)',
color: 'var(--foreground)'
}}
/>
<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>
<div className="space-y-3">
<div className="relative">
{loadingPrice ? (
<div className="w-full p-3 rounded-md flex items-center" style={{
background: 'var(--muted-light)',
border: '1px solid var(--input-border)',
color: 'var(--muted)'
}}>
<svg className="animate-spin h-4 w-4 mr-2" 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>
</div>
) : (
<input
type="text"
value={`$${deploymentCost ? deploymentCost.toPrecision(2) : null}`}
disabled={true}
className="w-full p-3 pr-12 rounded-md"
style={{
background: 'var(--card-bg)',
border: '1px solid var(--input-border)',
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)' }}>USD</span>
</div>
</div>
<div className="relative">
{loadingPrice ? (
<div className="w-full p-3 rounded-md flex items-center" style={{
background: 'var(--muted-light)',
border: '1px solid var(--input-border)',
color: 'var(--muted)'
}}>
<svg className="animate-spin h-4 w-4 mr-2" 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>
Fetching token amount...
</div>
) : (
<input
type="text"
value={getPaymentAmountDisplay()}
disabled={true}
className="w-full p-3 pr-20 rounded-md"
style={{
background: 'var(--card-bg)',
border: '1px solid var(--input-border)',
color: 'var(--foreground)',
opacity: '0.7'
}}
readOnly
/>
)}
{!(paymentMethod === PaymentMethod.SPL_TOKEN && loadingPrice) && (
<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)' }}>
{paymentMethod === PaymentMethod.NAT_GOR ? 'GOR (native)' : tokenSymbol}
</span>
</div>
)}
</div>
</div>
{paymentMethod === PaymentMethod.SPL_TOKEN && (
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
Token information fetched from <a className='text-blue-400 underline' href={`https://jup.ag/tokens/${mintAddress}`} target="_blank" rel="noopener noreferrer">Jupiter</a>
</p>
)}
</div>
{error && (
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: 'var(--error-light)', color: 'var(--error)' }}>
{error}
</div>
)}
</div>
<div className="p-6 flex justify-end space-x-4 border-t" style={{ borderColor: 'var(--card-border)' }}>
<button
onClick={onClose}
className="px-4 py-2 rounded-md transition-colors"
style={{
style={{
border: '1px solid var(--input-border)',
color: 'var(--foreground)',
opacity: loading ? '0.5' : '1'
@ -134,19 +289,21 @@ export default function PaymentModal({
onClick={handlePayment}
className="px-5 py-2 rounded-md flex items-center transition-colors"
style={{
backgroundColor: loading ? 'var(--muted)' : 'var(--primary)',
backgroundColor: (loading || loadingPrice) ? 'var(--muted)' : 'var(--primary)',
color: 'var(--primary-foreground)',
opacity: loading ? '0.8' : '1'
opacity: (loading || loadingPrice) ? '0.8' : '1'
}}
disabled={loading}
disabled={loading || loadingPrice}
>
{loading && (
{(loading || loadingPrice) && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" 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>
)}
{loading ? 'Processing...' : 'Pay with Keplr'}
{loadingPrice ? 'Loading Price...' :
loading ? 'Processing...' :
'Pay with Solana Wallet'}
</button>
</div>
</div>

View File

@ -9,8 +9,6 @@ interface StatusDisplayProps {
dns?: string;
appName?: string;
repoUrl?: string;
commitHash?: string;
shortCommitHash?: string;
error?: string;
}
@ -23,12 +21,10 @@ export default function StatusDisplay({
dns,
appName,
repoUrl,
commitHash,
shortCommitHash,
error,
}: StatusDisplayProps) {
// Get domain suffix from environment variable
const domainSuffix = process.env.NEXT_PUBLIC_DOMAIN_SUFFIX || '';
const domainSuffix = process.env.NEXT_PUBLIC_DOMAIN_SUFFIX;
if (status === 'idle') return null;
const StatusBadge = ({ type }: { type: 'creating' | 'success' | 'error' }) => {
@ -58,8 +54,8 @@ export default function StatusDisplay({
const styles = getBadgeStyles();
return (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold"
style={{ backgroundColor: styles.bg, color: styles.color }}>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold"
style={{ backgroundColor: styles.bg, color: styles.color }}>
{styles.text}
</span>
);
@ -108,7 +104,7 @@ export default function StatusDisplay({
</div>
)}
</div>
{status === 'success' && (
<div className="mt-4">
{appName && (
@ -124,15 +120,19 @@ export default function StatusDisplay({
)}
</div>
)}
{txHash && <InfoItem label="ATOM Payment Transaction Hash" value={txHash} />}
{txHash && <InfoItem label={`${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Payment Transaction Hash`} value={txHash} />}
{appRecordId && <InfoItem label="Laconic Application Record ID" value={appRecordId} />}
{recordId && <InfoItem label="Laconic Deployment Request Record ID" value={recordId} />}
{lrn && <InfoItem label="Laconic Resource Name (LRN)" value={lrn} />}
{dns && <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={domainSuffix ? `https://${dns}${domainSuffix}` : `http://${dns}`} />}
{dns && (
domainSuffix
? <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={`https://${dns}.${domainSuffix}`} />
: <InfoItem label="Please set domain suffix" value="" />
)}
</div>
)}
{status === 'error' && (
<div className="mt-4">
<div className="p-3 rounded-md mb-4" style={{ backgroundColor: 'var(--error-light)', color: 'var(--error)' }}>

View File

@ -10,46 +10,47 @@ interface URLFormProps {
export default function URLForm({ onSubmit, disabled }: URLFormProps) {
// Get example URL from environment variables or use a default
const exampleUrl = process.env.NEXT_PUBLIC_EXAMPLE_URL || 'https://example.com';
const [url, setUrl] = useState(exampleUrl);
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Trim the URL to remove any whitespace
const trimmedUrl = url.trim();
if (!trimmedUrl) {
setError('Please enter a URL');
return;
}
// Validate URL format
try {
const parsedUrl = new URL(trimmedUrl);
// Check for protocol
if (!parsedUrl.protocol.startsWith('http')) {
setError('URL must use HTTP or HTTPS protocol');
return;
}
// Check for hostname
if (!parsedUrl.hostname || parsedUrl.hostname.length < 3) {
setError('URL must contain a valid hostname');
return;
}
// Basic sanity check for common invalid URLs
if (parsedUrl.href === 'http://localhost' || parsedUrl.href === 'https://localhost') {
setError('Please enter a valid public URL, not localhost');
return;
}
// All validations passed
setError('');
onSubmit(trimmedUrl);
} catch (_) {
} catch (error) {
console.error(error);
setError('Please enter a valid URL (e.g., https://example.com)');
}
};
@ -68,7 +69,7 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
onChange={(e) => setUrl(e.target.value)}
placeholder={exampleUrl}
className="w-full p-3 rounded-md transition-colors"
style={{
style={{
background: 'var(--card-bg)',
border: '1px solid var(--input-border)',
color: 'var(--foreground)',
@ -91,19 +92,19 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
</p>
)}
</div>
<button
type="submit"
disabled={disabled || !url}
className="w-full px-6 py-3 rounded-md transition-colors"
style={{
style={{
backgroundColor: (disabled || !url) ? 'var(--muted)' : 'var(--primary)',
color: 'var(--primary-foreground)',
opacity: (disabled || !url) ? '0.7' : '1',
}}
>
{disabled ? 'Connect Wallet First' : 'Deploy URL'}
Deploy URL
</button>
</form>
);
}
}

View File

@ -0,0 +1,70 @@
'use client';
import { useMemo } from 'react';
import assert from 'assert';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { BackpackWalletAdapter } from '@solana/wallet-adapter-backpack';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
import { SolflareWalletAdapter } from '@solana/wallet-adapter-solflare';
import { usePaymentMethod } from '@/contexts/PaymentMethodContext';
import { PaymentMethod } from '@/types';
// Default styles that can be overridden by your app
import '@solana/wallet-adapter-react-ui/styles.css';
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
interface WalletProvidersProps {
children: React.ReactNode;
}
export default function WalletProviders({ children }: WalletProvidersProps) {
const { selectedPaymentMethod } = usePaymentMethod();
// Configure the Solana network endpoint
const endpoint = useMemo(() => {
return SOLANA_RPC_URL;
}, []);
// All available wallet adapters
const allWallets = useMemo(
() => [
new PhantomWalletAdapter(),
new SolflareWalletAdapter(),
new BackpackWalletAdapter()
],
[]
);
// Filter wallets based on selected payment method
const wallets = useMemo(() => {
if (!selectedPaymentMethod) {
// If no payment method selected, do not show any wallets
return [];
}
return allWallets.filter(wallet => {
const isBackpack = wallet.name.toLowerCase().includes('backpack');
if (selectedPaymentMethod === PaymentMethod.NAT_GOR) {
return isBackpack; // Only Backpack for native GOR
} else {
return !isBackpack; // Only non-Backpack wallets for SPL tokens
}
});
}, [allWallets, selectedPaymentMethod]);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}

View File

@ -1,11 +1,43 @@
import { DENOM as ALNT_DENOM, Registry } from '@cerc-io/registry-sdk';
import { RegistryConfig } from '../types';
import { GasPrice } from '@cosmjs/stargate';
let registryInstance: Registry | null = null;
export const getRegistry = (): Registry => {
if (!registryInstance) {
const config = getClientRegistryConfig();
const REGISTRY_GAS_PRICE = process.env.REGISTRY_GAS_PRICE;
const gasPrice = REGISTRY_GAS_PRICE ? GasPrice.fromString( REGISTRY_GAS_PRICE + ALNT_DENOM) : undefined;
registryInstance = new Registry(
config.gqlEndpoint,
config.rpcEndpoint,
{
chainId: config.chainId,
gasPrice,
}
);
}
return registryInstance;
};
export const getClientRegistryConfig = () => {
return {
chainId: process.env.NEXT_PUBLIC_REGISTRY_CHAIN_ID!,
rpcEndpoint: process.env.NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT!,
gqlEndpoint: process.env.NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT!,
};
};
export const getRegistryConfig = (): RegistryConfig => {
// Validate required environment variables
const requiredEnvVars = [
'REGISTRY_CHAIN_ID',
'REGISTRY_GQL_ENDPOINT',
'REGISTRY_RPC_ENDPOINT',
'NEXT_PUBLIC_REGISTRY_CHAIN_ID',
'NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT',
'NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT',
'REGISTRY_BOND_ID',
'REGISTRY_AUTHORITY',
'REGISTRY_USER_KEY'
@ -18,29 +50,11 @@ export const getRegistryConfig = (): RegistryConfig => {
}
return {
chainId: process.env.REGISTRY_CHAIN_ID!,
rpcEndpoint: process.env.REGISTRY_RPC_ENDPOINT!,
gqlEndpoint: process.env.REGISTRY_GQL_ENDPOINT!,
chainId: process.env.NEXT_PUBLIC_REGISTRY_CHAIN_ID!,
rpcEndpoint: process.env.NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT!,
gqlEndpoint: process.env.NEXT_PUBLIC_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: process.env.REGISTRY_GAS_PRICE || '0.025',
},
};
};
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

@ -0,0 +1,9 @@
import { PaymentMethod } from "@/types";
// Payment method labels for UI
export const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
[PaymentMethod.NAT_GOR]: 'GOR (native)',
[PaymentMethod.SPL_TOKEN]: process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL!
};
export const SOLANA_GOR_MINT_ADDRESS = '71Jvq4Epe2FCJ7JFSF7jLXdNk1Wy4Bhqd9iL6bEFELvg';

View File

@ -0,0 +1,39 @@
'use client';
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { PaymentMethod } from '@/types';
interface PaymentMethodContextType {
selectedPaymentMethod: PaymentMethod | null;
setSelectedPaymentMethod: (method: PaymentMethod | null) => void;
}
const PaymentMethodContext = createContext<PaymentMethodContextType | undefined>(undefined);
interface PaymentMethodProviderProps {
children: ReactNode;
}
export function PaymentMethodProvider({ children }: PaymentMethodProviderProps) {
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(null);
return (
<PaymentMethodContext.Provider
value={{
selectedPaymentMethod,
setSelectedPaymentMethod
}}
>
{children}
</PaymentMethodContext.Provider>
);
}
export function usePaymentMethod() {
const context = useContext(PaymentMethodContext);
if (context === undefined) {
throw new Error('usePaymentMethod must be used within a PaymentMethodProvider');
}
return context;
}

View File

@ -0,0 +1,67 @@
interface JupiterPriceResponse {
[mintAddress: string]: {
usdPrice: number;
blockId: number;
decimals: number;
priceChange24h: number;
};
}
interface TokenPriceInfo {
usdPrice: number;
decimals: number;
}
export interface RequiredTokenInfo {
requiredAmountInBaseUnits: number;
decimals: number;
}
/**
* Fetches token price from Jupiter aggregator API
* @param mintAddress The Solana token mint address
* @returns Token price information including USD price and decimals
*/
async function getTokenInfo(mintAddress: string): Promise<TokenPriceInfo> {
try {
const response = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mintAddress}`);
if (!response.ok) {
throw new Error(`Jupiter API error: ${response.status} ${response.statusText}`);
}
const data: JupiterPriceResponse = await response.json();
if (!data[mintAddress]) {
throw new Error(`Price not found for token: ${mintAddress}`);
}
const tokenInfo = data[mintAddress];
return {
usdPrice: tokenInfo.usdPrice,
decimals: tokenInfo.decimals
};
} catch (error) {
console.error('Error fetching token price from Jupiter:', error);
throw error;
}
}
/**
* Calculates the token amount needed for a given USD price
* @param targetUsdAmount The target amount in USD
* @param mintAddress The Solana token mint address
* @returns The token amount (in smallest units) needed and token decimals
*/
export async function getRequiredTokenInfo(targetUsdAmount: number, mintAddress: string): Promise<RequiredTokenInfo> {
const priceInfo = await getTokenInfo(mintAddress);
// Calculate token amount needed
const tokenAmount = targetUsdAmount / priceInfo.usdPrice;
// Convert to smallest units (considering decimals)
const requiredAmountInBaseUnits = Math.round(tokenAmount * Math.pow(10, priceInfo.decimals));
return {requiredAmountInBaseUnits, decimals: priceInfo.decimals};
}

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,115 @@
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,25 +1,34 @@
import axios from 'axios';
import { CreateRecordResponse } from '../types';
import assert from 'assert';
import { getRegistry } from '@/config';
import { CreateRecordResponse, PricingRecordAttributes, PaymentMethod } from '../types';
assert(process.env.NEXT_PUBLIC_DEPLOYMENT_COST_LRN, 'DEPLOYMENT_COST_LRN is required');
assert(process.env.NEXT_PUBLIC_ALNT_COST_LRN, 'ALNT_COST_LRN is required');
const DEPLOYMENT_COST_LRN = process.env.NEXT_PUBLIC_DEPLOYMENT_COST_LRN;
const ALNT_COST_LRN = process.env.NEXT_PUBLIC_ALNT_COST_LRN;
export const createApplicationDeploymentRequest = async (
url: string,
txHash: string
txHash: string,
paymentMethod: PaymentMethod,
): Promise<CreateRecordResponse> => {
try {
console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash}`);
console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash} using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment`);
// Call our serverless API endpoint to handle the registry interaction
const response = await fetch('/api/registry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, txHash }),
body: JSON.stringify({ url, txHash, paymentMethod }),
});
const result = await response.json();
console.log('API response:', result);
if (response.ok && result.status === 'success') {
return {
id: result.id,
@ -50,63 +59,33 @@ 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;
const resolvePricingRecordLrns = async (lrns: string[]): Promise<PricingRecordAttributes[]> => {
const registry = getRegistry();
const result = await registry.resolveNames(lrns);
const pricingRecordsAttributes: PricingRecordAttributes[] = result.map((record: any) => {
return record.attributes
});
return pricingRecordsAttributes;
};
export const getCostOfDeployment = async (): Promise<number> => {
const resolvedRecords = await resolvePricingRecordLrns([ALNT_COST_LRN, DEPLOYMENT_COST_LRN]);
console.log('resolvedRecords:', resolvedRecords);
// Find the ALNT price record (USD per ALNT)
const alntPriceRecord = resolvedRecords[0];
// Find the deployment cost record (ALNT cost for webapp-deployment)
const deploymentCostRecord = resolvedRecords[1];
if (!alntPriceRecord || !deploymentCostRecord) {
throw new Error('Required pricing records not found');
}
};
// Convert strings to numbers for calculation
const alntPriceUsd = parseFloat(alntPriceRecord.amount); // USD per ALNT
const deploymentCostAlnt = parseFloat(deploymentCostRecord.amount); // ALNT required
// Calculate deployment cost in USD: (ALNT required) * (USD per ALNT)
return deploymentCostAlnt * alntPriceUsd;
}

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

@ -0,0 +1,267 @@
import assert from 'assert';
import BN from 'bn.js';
import { Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import {
TOKEN_PROGRAM_ID,
createTransferInstruction,
createAssociatedTokenAccountInstruction,
ASSOCIATED_TOKEN_PROGRAM_ID,
getAssociatedTokenAddressSync
} from '@solana/spl-token';
import { WalletAdapter } from '@solana/wallet-adapter-base';
import { SolanaPaymentResult, PaymentRequest, PaymentMethod } 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;
// Helper function to find associated token address
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];
}
// Send native GOR payment
export async function sendNativeGorPayment(
wallet: WalletAdapter,
connection: Connection,
walletPublicKey: string,
tokenAmount: BN
): Promise<SolanaPaymentResult> {
try {
if (!wallet.connected || !wallet.publicKey) {
throw new Error('Wallet not connected');
}
const senderPublicKey = new PublicKey(walletPublicKey);
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
console.log('Processing native GOR payment:', {
sender: senderPublicKey.toBase58(),
receiver: receiverPublicKey.toBase58(),
amount: tokenAmount.toString()
});
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: senderPublicKey,
toPubkey: receiverPublicKey,
lamports: BigInt(tokenAmount.toString()),
})
);
// Set transaction details
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
transaction.recentBlockhash = latestBlockhash.blockhash;
transaction.feePayer = senderPublicKey;
console.log('Sending native GOR transaction...');
const signature = await wallet.sendTransaction(transaction, connection);
console.log('Native GOR 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('Native GOR payment error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Native GOR payment failed'
};
}
}
// Send SPL token payment
export async function sendSplTokenPayment(
wallet: WalletAdapter,
connection: Connection,
walletPublicKey: string,
tokenAmount: BN
): Promise<SolanaPaymentResult> {
try {
if (!wallet.connected || !wallet.publicKey) {
throw new Error('Wallet not connected');
}
const senderPublicKey = new PublicKey(walletPublicKey);
const mintPublicKey = new PublicKey(TOKEN_MINT);
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
console.log('Processing SPL token payment:', {
sender: senderPublicKey.toBase58(),
mint: mintPublicKey.toBase58(),
receiver: receiverPublicKey.toBase58(),
amount: tokenAmount.toString()
});
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 SPL token transaction...');
const signature = await wallet.sendTransaction(transaction, connection);
console.log('SPL token 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('SPL token payment error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'SPL token payment failed'
};
}
}
// Main payment function that routes to the appropriate payment method
export async function sendSolanaPayment(
wallet: WalletAdapter,
connection: Connection,
walletPublicKey: string,
paymentRequest: PaymentRequest
): Promise<SolanaPaymentResult> {
try {
if (!wallet.connected || !wallet.publicKey) {
throw new Error('Wallet not connected');
}
const tokenAmount = new BN(paymentRequest.amount);
switch (paymentRequest.paymentMethod) {
case PaymentMethod.NAT_GOR:
return await sendNativeGorPayment(wallet, connection, walletPublicKey, tokenAmount);
case PaymentMethod.SPL_TOKEN:
return await sendSplTokenPayment(wallet, connection, walletPublicKey, tokenAmount);
default:
throw new Error(`Unsupported payment method: ${paymentRequest.paymentMethod}`);
}
} catch (error) {
console.error('Payment error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Payment failed'
};
}
}
// Helper function to get recipient address based on payment method
export function getRecipientAddress(paymentMethod: PaymentMethod): string {
switch (paymentMethod) {
case PaymentMethod.NAT_GOR:
// For native GOR payments, use the direct recipient address
return PAYMENT_RECEIVER_ADDRESS;
case PaymentMethod.SPL_TOKEN:
// For SPL token payments, use the associated token account
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
const mintPublicKey = new PublicKey(TOKEN_MINT);
const associatedTokenAddress = getAssociatedTokenAddressSync(
mintPublicKey,
receiverPublicKey,
true // Allow off-curve addresses
);
return associatedTokenAddress.toBase58();
default:
throw new Error(`Unsupported payment method: ${paymentMethod}`);
}
}

View File

@ -1,10 +1,8 @@
import { Window as KeplrWindow } from "@keplr-wallet/types";
// extend the global Window interface to include Keplr
declare global {
interface Window extends KeplrWindow {}
// Payment method types
export enum PaymentMethod {
NAT_GOR = 'nat-gor',
SPL_TOKEN = 'spl-token'
}
export interface RegistryConfig {
chainId: string;
rpcEndpoint: string;
@ -12,32 +10,6 @@ export interface RegistryConfig {
bondId: string;
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 {
@ -51,4 +23,38 @@ export interface CreateRecordResponse {
shortCommitHash?: string;
status: 'success' | 'error';
message?: string;
}
}
export interface SolanaPaymentResult {
success: boolean;
transactionSignature?: string;
error?: string;
}
export interface PaymentModalProps {
isOpen: boolean;
onClose: () => void;
url: string;
onPaymentComplete: (txHash: string, paymentMethod: PaymentMethod) => void;
}
export interface PaymentRequest {
paymentMethod: PaymentMethod;
amount: number; // in base units (lamports for native GOR, token base units for SPL)
}
export interface LaconicTransferResult {
success: boolean;
transactionHash?: string;
error?: string;
}
export interface PricingRecordAttributes {
amount: string;
currency: string;
for: string;
type: string;
version: string;
}

1
src/utils/gorbagana.ts Normal file
View File

@ -0,0 +1 @@
export const IS_NAT_GOR_TRANSFER_ENABLED = process.env.NEXT_PUBLIC_ENABLE_NATIVE_GOR_TRANSFER !== "false";

158
src/utils/solana-verify.ts Normal file
View File

@ -0,0 +1,158 @@
import BN from 'bn.js';
import { Connection, ParsedInstruction, PartiallyDecodedInstruction } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { getRecipientAddress } from '@/services/solana';
import { PaymentMethod } from '../types';
// Extract transaction info for native GOR transfers
const extractTxInfo = async (
connection: Connection,
transactionSignature: string,
paymentMethod: PaymentMethod
): Promise<{ authority: string; amount: string; destination: string }> => {
const result = await connection.getParsedTransaction(transactionSignature, 'confirmed');
if (!result) {
throw new Error('Transaction not found');
}
let transferInstruction: ParsedInstruction | PartiallyDecodedInstruction | undefined;
switch (paymentMethod) {
case PaymentMethod.NAT_GOR:
// Look for system program transfer instruction
transferInstruction = result.transaction.message.instructions.find(
(instr) => 'parsed' in instr && instr.parsed.type === 'transfer'
);
if (!transferInstruction || !('parsed' in transferInstruction)) {
throw new Error('Native GOR transfer instruction not found');
}
const { info: { lamports, source, destination } } = transferInstruction.parsed;
return { authority: source, amount: lamports.toString(), destination };
case PaymentMethod.SPL_TOKEN:
// Look for token transfer instruction using TOKEN_PROGRAM_ID
transferInstruction = result.transaction.message.instructions.find(
(instr) => 'parsed' in instr && instr.programId.equals(TOKEN_PROGRAM_ID)
);
if (!transferInstruction || !('parsed' in transferInstruction)) {
throw new Error('SPL token transfer instruction not found');
}
const parsed = transferInstruction.parsed;
// Handle both transferChecked and transfer types
if (parsed.type === 'transferChecked') {
const { info: { tokenAmount, authority, destination } } = parsed;
return {
authority,
amount: tokenAmount.amount,
destination
};
} else if (parsed.type === 'transfer') {
const { info: { amount, authority, destination } } = parsed;
return { authority, amount, destination };
}
throw new Error('Unsupported token transfer type');
default:
throw new Error('Invalid payment method');
}
};
export const verifyUnusedSolanaPayment = async (
connection: Connection,
transactionSignature: string,
expectedAmount: BN,
paymentMethod: PaymentMethod,
): 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 based on payment method
const transferInfo = await extractTxInfo(connection, transactionSignature, paymentMethod);
const amount = transferInfo.amount;
const authority = transferInfo.authority;
const destination = transferInfo.destination;
// Verify amount using BN comparison
const transactionAmount = new BN(amount);
if (transactionAmount.lt(expectedAmount)) {
return {
valid: false,
reason: `Payment amount (${amount}) is less than required (${expectedAmount.toString()})`
};
}
// Verify recipient address
const expectedRecipientAddress = getRecipientAddress(paymentMethod);
if (destination !== expectedRecipientAddress) {
return {
valid: false,
reason: `Invalid recipient address. Expected: ${expectedRecipientAddress}, Got: ${destination}`
};
}
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"]
}