Compare commits

..

10 Commits

Author SHA1 Message Date
41add0a99a Set Gorbagana chain ID in Application deployment request record (#11)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: #11
Co-authored-by: shreerang <shreerang@noreply.git.vdb.to>
Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
2025-08-08 06:51:54 +00:00
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
38 changed files with 6234 additions and 868 deletions

View File

@ -1,30 +1,32 @@
# Client-side environment variables (must be prefixed with NEXT_PUBLIC_) # Client-side environment variables must be prefixed with NEXT_PUBLIC_
# Solana Token Payment Configuration # Solana Payment Configuration
# TODO: Use different RPC URL or use browser wallet # TODO: Use different RPC URL
NEXT_PUBLIC_SOLANA_RPC_URL=https://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158 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_MINT_ADDRESS=71Jvq4Epe2FCJ7JFSF7jLXdNk1Wy4Bhqd9iL6bEFELvg
# Multisig address
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6
NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400000000 # Approx. 5 USD # 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 # UI Configuration
NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app
# Server-side environment variables
# Laconic Registry Configuration # Laconic Registry Configuration
REGISTRY_CHAIN_ID=laconic-mainnet NEXT_PUBLIC_REGISTRY_CHAIN_ID=laconic-mainnet
REGISTRY_RPC_ENDPOINT=https://laconicd-mainnet-1.laconic.com NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT=https://laconicd-mainnet-1.laconic.com
REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/graphql NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT=https://laconicd-mainnet-1.laconic.com/graphql
REGISTRY_BOND_ID= NEXT_PUBLIC_ALNT_COST_LRN=lrn://laconic/pricing/alnt
REGISTRY_AUTHORITY= NEXT_PUBLIC_DEPLOYMENT_COST_LRN=lrn://laconic/pricing/webapp-deployment
REGISTRY_USER_KEY=
REGISTRY_GAS_PRICE=0.001 REGISTRY_GAS_PRICE=0.001
REGISTRY_AUTHORITY=laconic-deploy
REGISTRY_BOND_ID=5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2
REGISTRY_USER_KEY=
# Application Configuration # Application Configuration
DEPLOYER_LRN= DEPLOYER_LRN=lrn://vaasl-provider/deployers/webapp-deployer-api.apps.vaasl.io
NEXT_PUBLIC_DOMAIN_SUFFIX= 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"
}
};

3
.gitignore vendored
View File

@ -43,3 +43,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
# 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

102
README.md
View File

@ -20,35 +20,9 @@ A simple Next.js frontend that allows users to pay in GOR tokens (configurable S
- Solana wallet browser extension (Phantom or Solflare) - Solana wallet browser extension (Phantom or Solflare)
- Access to a Laconic Registry node - 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: Follow [these steps](./deploy/README.md) to deploy this app to production
```bash
cp .env.local.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
- `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` - The mint address of the SPL token to accept
- `NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS` - The Solana address that will receive token payments
- `NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL` - The token symbol to display (e.g., "GOR")
- `NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS` - The number of decimals for the token (e.g., 6)
- `NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT` - The fixed payment amount required (e.g., 400)
- `NEXT_PUBLIC_DOMAIN_SUFFIX` - Optional suffix to append to DNS names in the UI (e.g. ".example.com")
- `NEXT_PUBLIC_EXAMPLE_URL` - Example URL to pre-fill in the URL form (e.g. "https://github.com/cerc-io/laconic-registry-cli")
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 (also used for LNT transfers)
- `APP_NAME` - The name of the application (used in record creation, defaults to "gor-deploy")
- `DEPLOYER_LRN` - The LRN of the deployer
## Installation ## Installation
@ -58,6 +32,35 @@ npm install
## Development ## 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 ```bash
npm run dev npm run dev
``` ```
@ -130,43 +133,6 @@ This application was built with reference to:
- `snowballtools-base/packages/backend/src/registry.ts` - `snowballtools-base/packages/backend/src/registry.ts`
- Original `hosted-frontends/deploy-atom.sh` (adapted for Solana/GOR) - Original `hosted-frontends/deploy-atom.sh` (adapted for Solana/GOR)
## 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 gor-deploy .
docker run -p 3000:3000 --env-file .env.production gor-deploy
```
## Known Issues ## Known Issues
- You may see a deprecated Buffer() warning during build. This comes from dependencies in the registry-sdk. This doesn't affect functionality. - You may see a deprecated Buffer() warning during build. This comes from dependencies in the registry-sdk. This doesn't affect functionality.
@ -181,12 +147,6 @@ docker run -p 3000:3000 --env-file .env.production gor-deploy
- **Connection issues**: Ensure the wallet is unlocked and try refreshing 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. - **Transaction failures**: Check that you have sufficient SOL for transaction fees and enough tokens for the payment.
### Token Configuration
- **Wrong token**: Verify the `NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS` matches your desired SPL token.
- **Incorrect decimals**: Ensure `NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS` matches the token's actual decimal count.
- **Payment amount**: Adjust `NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT` to the desired payment amount.
### Laconic Registry Issues ### Laconic Registry Issues
- **Failed to create record**: Check that your REGISTRY_USER_KEY and REGISTRY_BOND_ID are correctly set. - **Failed to create record**: Check that your REGISTRY_USER_KEY and REGISTRY_BOND_ID are correctly set.

View File

@ -1,10 +1,10 @@
# ENV for registry operations # ENV for registry operations
# Bond to use # Bond to use
REGISTRY_BOND_ID= REGISTRY_BOND_ID=5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2
# Target deployer LRN # Target deployer LRN
DEPLOYER_LRN= DEPLOYER_LRN=lrn://vaasl-provider/deployers/webapp-deployer-api.apps.vaasl.io
# Authority to deploy the app under # Authority to deploy the app under
AUTHORITY= AUTHORITY=laconic-deploy

View File

@ -4,14 +4,14 @@
### gor-deploy ### gor-deploy
* Clone the repo: - Clone the repo:
```bash ```bash
git clone git@git.vdb.to:LaconicNetwork/gor-deploy.git git clone git@git.vdb.to:LaconicNetwork/gor-deploy.git
cd gor-deploy/deploy cd gor-deploy/deploy
``` ```
* Build registry CLI image: - Build registry CLI image:
```bash ```bash
docker build -t cerc/laconic-registry-cli . docker build -t cerc/laconic-registry-cli .
@ -19,53 +19,86 @@
# Builds image cerc/laconic-registry-cli:latest # Builds image cerc/laconic-registry-cli:latest
``` ```
* Configure `userKey` and `bondId` in the [registry CLI config](./config.yml): - 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 ```bash
nano config.yml nano config.yml
``` ```
* Add configuration for registry operations: - 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 ```bash
cp .registry.env.example .registry.env cp .registry.env.example .registry.env
# Fill in the required values # Update values if required
nano .registry.env nano .registry.env
``` ```
* Add configuration for the app: - Add configuration for the app:
<!-- TODO: Use a release -->
```bash ```bash
curl -s https://git.vdb.to/LaconicNetwork/gor-deploy/raw/branch/sk-gor-deploy/.env.example -o .app.env curl -s https://git.vdb.to/LaconicNetwork/gor-deploy/raw/branch/main/.env.example -o .app.env
# Fill in the required values # Fill in the required values
nano .app.env 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 ## Run
### gor-deploy - Deploy `gor-deploy` App:
* Deploy `gor-deploy` App:
<!-- TODO: Change deploy dns -->
```bash ```bash
# In gor-deploy/deploy dir # In gor-deploy/deploy dir
docker run -it \ docker run -it \
-v ./:/app/deploy -w /app/deploy \ -v ./:/app/deploy -w /app/deploy \
-e DEPLOYMENT_DNS=mtm-deploy \ -e DEPLOYMENT_DNS=gor-deploy.laconic.com \
cerc/laconic-registry-cli:latest \ cerc/laconic-registry-cli:latest \
./deploy.sh ./deploy.sh
``` ```
* Check deployment logs on deployer UI: <https://webapp-deployer-ui.apps.vaasl.io/> - Check deployment logs on deployer UI: <https://webapp-deployer-ui.apps.vaasl.io/>
* Visit deployed app: <https://gor-deploy.apps.vaasl.io> - Visit deployed app: <https://gor-deploy.apps.vaasl.io>
### remove deployment ### Remove deployment
* Remove deployment: - Remove deployment:
```bash ```bash
# In gor-deploy/deploy dir # In gor-deploy/deploy dir

View File

@ -4,6 +4,6 @@ services:
rpcEndpoint: 'https://laconicd-mainnet-1.laconic.com' rpcEndpoint: 'https://laconicd-mainnet-1.laconic.com'
gqlEndpoint: 'https://laconicd-mainnet-1.laconic.com/api' gqlEndpoint: 'https://laconicd-mainnet-1.laconic.com/api'
userKey: userKey:
bondId: bondId: 5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2
chainId: laconic-mainnet chainId: laconic-mainnet
gasPrice: 0.001alnt gasPrice: 0.001alnt

View File

@ -12,15 +12,12 @@ echo "Using AUTHORITY: $AUTHORITY"
REPO_URL="https://git.vdb.to/LaconicNetwork/gor-deploy" REPO_URL="https://git.vdb.to/LaconicNetwork/gor-deploy"
# Get the latest commit hash for a branch # Get the latest commit hash for a branch
# TODO: Change to main before merging PR BRANCH_NAME="main"
BRANCH_NAME="sk-gor-deploy"
LATEST_HASH=$(git ls-remote $REPO_URL refs/heads/$BRANCH_NAME | awk '{print $1}') LATEST_HASH=$(git ls-remote $REPO_URL refs/heads/$BRANCH_NAME | awk '{print $1}')
# TODO: Use a release
PACKAGE_VERSION=$(curl -s $REPO_URL/raw/branch/$BRANCH_NAME/package.json | jq -r .version) PACKAGE_VERSION=$(curl -s $REPO_URL/raw/branch/$BRANCH_NAME/package.json | jq -r .version)
# TODO: Update app name APP_NAME=gor-deploy
APP_NAME=mtm-deploy
echo "Repo: ${REPO_URL}" echo "Repo: ${REPO_URL}"
echo "Latest hash: ${LATEST_HASH}" echo "Latest hash: ${LATEST_HASH}"

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

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

View File

@ -11,6 +11,19 @@ const compat = new FlatCompat({
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...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; export default eslintConfig;

4621
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,15 +6,21 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"laconic": "laconic registry -c deploy/config.yml"
}, },
"dependencies": { "dependencies": {
"@cerc-io/registry-sdk": "^0.2.11", "@cerc-io/registry-sdk": "^0.2.11",
"@cosmjs/stargate": "^0.32.3", "@cosmjs/stargate": "^0.32.3",
"@solana/spl-token": "^0.4.13", "@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", "@solana/web3.js": "^1.98.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"big.js": "^6.2.2",
"bn.js": "^5.2.2", "bn.js": "^5.2.2",
"next": "15.3.1", "next": "15.3.1",
"react": "^19.0.0", "react": "^19.0.0",
@ -23,10 +29,13 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@cerc-io/laconic-registry-cli": "^0.2.9",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^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", "eslint-config-next": "15.3.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"

View File

@ -3,19 +3,29 @@ import { NextRequest, NextResponse } from 'next/server';
import axios from 'axios'; import axios from 'axios';
import assert from 'assert'; import assert from 'assert';
import { GasPrice } from '@cosmjs/stargate'; import { Connection, ParsedTransactionWithMeta } from '@solana/web3.js';
import { Connection } from '@solana/web3.js';
import { DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
import { verifyUnusedSolanaPayment } from '@/utils/solanaVerify'; import { verifyUnusedSolanaPayment } from '@/utils/solana-verify';
import { transferLNTTokens } from '@/services/laconicTransfer'; import { transferLNTTokens } from '@/services/laconic-transfer';
import { getRegistry, getRegistryConfig } from '@/config'; 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(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 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 // Use CAIP convention for chain ID: namespace + reference
const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // Solana mainnet const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // Solana mainnet
const GORBAGANA_CHAIN_ID = 'gorbagana:533uBE9RRquhTBqEX58oV52FdTTsReMd' // Gorbagana chain (first 32 characters of gorbagana genesis hash. Following solana CAIP chain ID pattern)
// Sleep helper function // Sleep helper function
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@ -130,21 +140,44 @@ export const registryTransactionWithRetry = async (
throw lastError; throw lastError;
}; };
let connection: Connection; 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) { export async function POST(request: NextRequest) {
try { try {
if (!connection) {
connection = new Connection(SOLANA_RPC_URL);
}
// First check if the request body is valid JSON // First check if the request body is valid JSON
let url, txHash, senderPublicKey; let url, txHash, senderPublicKey, paymentMethod;
let connection: Connection;
let parsedTx: ParsedTransactionWithMeta | null;
try { try {
const body = await request.json(); const body = await request.json();
url = body.url; url = body.url;
txHash = body.txHash; txHash = body.txHash;
paymentMethod = body.paymentMethod as PaymentMethod;
// Get the appropriate connection based on payment method
connection = getConnection(paymentMethod);
const tx = await connection.getParsedTransaction(txHash, 'confirmed'); const tx = await connection.getParsedTransaction(txHash, 'confirmed');
if (!tx) { if (!tx) {
@ -155,16 +188,26 @@ export async function POST(request: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
parsedTx = tx;
const signerKeys = tx.transaction.message.accountKeys const signerKeys = tx.transaction.message.accountKeys
.filter(k => k.signer) .filter(k => k.signer)
.map(k => k.pubkey.toBase58()); .map(k => k.pubkey.toBase58());
senderPublicKey = signerKeys[0]; senderPublicKey = signerKeys[0];
if (!url || !txHash) { if (!url || !txHash || !paymentMethod) {
return NextResponse.json({ return NextResponse.json({
status: 'error', status: 'error',
message: 'Missing required fields: url and txHash are required' 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 }); }, { status: 400 });
} }
} catch (error) { } catch (error) {
@ -178,19 +221,52 @@ export async function POST(request: NextRequest) {
// Verify Solana payment // Verify Solana payment
console.log('Step 0: Verifying Solana token payment...'); console.log('Step 0: Verifying Solana token payment...');
const paymentAmount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!); let requiredTokenInfo: RequiredTokenInfo;
const tokenAmount = new BN(paymentAmount); const targetUsdAmount = await getCostOfDeployment();
const solanaPaymentResult = await verifyUnusedSolanaPayment(connection, txHash, tokenAmount); 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);
if (!parsedTx) {
throw new Error(`Unable to find the tx with hash: ${txHash}`)
}
const solanaPaymentResult = await verifyUnusedSolanaPayment(
connection,
parsedTx,
new BN(expectedTokenAmount),
paymentMethod,
);
if (!solanaPaymentResult.valid) { if (!solanaPaymentResult.valid) {
console.error('Solana token payment verification failed:', solanaPaymentResult.reason); console.error(`Solana ${paymentMethod} payment verification failed:`, solanaPaymentResult.reason);
return NextResponse.json({ return NextResponse.json({
status: 'error', status: 'error',
message: `Payment verification failed: ${solanaPaymentResult.reason}` message: `Payment verification failed: ${solanaPaymentResult.reason}`
}, { status: 400 }); }, { status: 400 });
} }
console.log('Solana token payment verified successfully:', { console.log(`Solana ${paymentMethod} payment verified successfully:`, {
amount: solanaPaymentResult.amount, amount: solanaPaymentResult.amount,
sender: solanaPaymentResult.sender sender: solanaPaymentResult.sender
}); });
@ -208,13 +284,13 @@ export async function POST(request: NextRequest) {
} }
console.log('LNT transfer completed:', lntTransferResult.transactionHash); console.log('LNT transfer completed:', lntTransferResult.transactionHash);
const finalTxHash = lntTransferResult.transactionHash!; // Use LNT transfer hash for registry const laconicTxHash = lntTransferResult.transactionHash!; // Use LNT transfer hash for registry
// Validate required environment variables for Solana payments // Validate required environment variables for Solana payments
const requiredEnvVars = [ const requiredEnvVars = [
'REGISTRY_CHAIN_ID', 'NEXT_PUBLIC_REGISTRY_CHAIN_ID',
'REGISTRY_GQL_ENDPOINT', 'NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT',
'REGISTRY_RPC_ENDPOINT', 'NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT',
'REGISTRY_BOND_ID', 'REGISTRY_BOND_ID',
'REGISTRY_AUTHORITY', 'REGISTRY_AUTHORITY',
'REGISTRY_USER_KEY', // This is the same as the prefilled account for LNT transfers 'REGISTRY_USER_KEY', // This is the same as the prefilled account for LNT transfers
@ -282,10 +358,6 @@ export async function POST(request: NextRequest) {
const deployerLrn = process.env.DEPLOYER_LRN!; const deployerLrn = process.env.DEPLOYER_LRN!;
// Create Registry client instance
const gasPrice = GasPrice.fromString(config.fee.gasPrice + ALNT_DENOM);
console.log('Using manual gas price:', gasPrice);
const registry = getRegistry() const registry = getRegistry()
// Create LRN for the application with commit hash // Create LRN for the application with commit hash
@ -391,13 +463,13 @@ export async function POST(request: NextRequest) {
note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`, note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`,
repository: repoUrl, repository: repoUrl,
repository_ref: fullHash, repository_ref: fullHash,
},
external_payment: { external_payment: {
chain_id: SOLANA_CHAIN_ID, chain_id: paymentMethod === PaymentMethod.SPL_TOKEN ? SOLANA_CHAIN_ID : GORBAGANA_CHAIN_ID,
tx_hash: txHash, tx_hash: txHash,
pubkey: senderPublicKey pubkey: senderPublicKey
}
}, },
payment: finalTxHash, payment: laconicTxHash,
}; };
console.log('Deployment request data:', deploymentRequestData); console.log('Deployment request data:', deploymentRequestData);

View File

@ -3,6 +3,9 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper"; import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper";
import WalletProviders from "../components/WalletProviders";
import { PaymentMethodProvider } from "../contexts/PaymentMethodContext";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -30,7 +33,11 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ErrorBoundaryWrapper /> <ErrorBoundaryWrapper />
<PaymentMethodProvider>
<WalletProviders>
{children} {children}
</WalletProviders>
</PaymentMethodProvider>
</body> </body>
</html> </html>
); );

View File

@ -1,24 +1,44 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import dynamic from 'next/dynamic'; 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 URLForm from '@/components/URLForm';
import StatusDisplay from '@/components/StatusDisplay'; import StatusDisplay from '@/components/StatusDisplay';
import { createApplicationDeploymentRequest } from '@/services/registry'; import { createApplicationDeploymentRequest } from '@/services/registry';
import { SolanaWalletState } from '@/types'; 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 // Dynamically import components to avoid SSR issues with browser APIs
const SolanaConnect = dynamic(() => import('@/components/SolanaConnect'), { ssr: false });
const PaymentModal = dynamic(() => import('@/components/PaymentModal'), { ssr: false }); 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() { export default function Home() {
const [showWalletConnection, setShowWalletConnection] = useState(false); const { wallet, connected, publicKey, disconnect } = useWallet();
const [solanaWalletState, setSolanaWalletState] = useState<SolanaWalletState>({
connected: false, const { selectedPaymentMethod, setSelectedPaymentMethod } = usePaymentMethod();
publicKey: null,
walletType: null
});
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false);
const [status, setStatus] = useState<'idle' | 'creating' | 'success' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'creating' | 'success' | 'error'>('idle');
@ -29,21 +49,89 @@ export default function Home() {
const [dns, setDns] = useState<string | null>(null); const [dns, setDns] = useState<string | null>(null);
const [appName, setAppName] = useState<string | null>(null); const [appName, setAppName] = useState<string | null>(null);
const [repoUrl, setRepoUrl] = 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 [error, setError] = useState<string | null>(null);
const [incorrectChainWarning, setIncorrectChainWarning] = useState<string | null>(null);
const [isFetchingChainGenesisHash, setIsFetchingChainGenesisHash] = useState(false);
const handleConnectWallet = () => { useEffect(() => {
setShowWalletConnection(true); 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) => { const handleUrlSubmit = (submittedUrl: string) => {
setUrl(submittedUrl); setUrl(submittedUrl);
setShowPaymentModal(true); setShowPaymentModal(true);
}; };
const handlePaymentComplete = useCallback(async (hash: string) => { // Helper function to check if current wallet is compatible with selected payment method
if (!solanaWalletState.publicKey || !url) { 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 return
} }
@ -53,7 +141,7 @@ export default function Home() {
try { try {
// Create the Laconic Registry record (payment verification is done in the API) // Create the Laconic Registry record (payment verification is done in the API)
const result = await createApplicationDeploymentRequest(url, hash); const result = await createApplicationDeploymentRequest(url, hash, paymentMethod);
if (result.status === 'success') { if (result.status === 'success') {
setRecordId(result.id); setRecordId(result.id);
@ -72,12 +160,6 @@ export default function Home() {
if (result.repoUrl) { if (result.repoUrl) {
setRepoUrl(result.repoUrl); setRepoUrl(result.repoUrl);
} }
if (result.commitHash) {
setCommitHash(result.commitHash);
}
if (result.shortCommitHash) {
setShortCommitHash(result.shortCommitHash);
}
setStatus('success'); setStatus('success');
} else { } else {
setStatus('error'); setStatus('error');
@ -87,7 +169,7 @@ export default function Home() {
setStatus('error'); setStatus('error');
setError(error instanceof Error ? error.message : 'An unknown error occurred'); setError(error instanceof Error ? error.message : 'An unknown error occurred');
} }
}, [solanaWalletState, url]) }, [publicKey, url])
const handleClosePaymentModal = () => { const handleClosePaymentModal = () => {
setShowPaymentModal(false); setShowPaymentModal(false);
@ -101,56 +183,150 @@ export default function Home() {
Deploy Frontends with {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} + Laconic Deploy Frontends with {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} + Laconic
</h1> </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)' }}> <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"> <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" Choose Payment Method
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}>1</span>
Connect Your Wallet
</h2> </h2>
{!showWalletConnection ? ( <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="text-center">
<p className="mb-4" style={{ color: 'var(--muted-foreground)' }}>
Payment method: <span className="font-semibold" style={{ color: 'var(--foreground)' }}>
{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} (Solana)
</span>
</p>
<button <button
onClick={handleConnectWallet} onClick={() => setSelectedPaymentMethod(PaymentMethod.NAT_GOR)}
className="px-6 py-2 rounded-lg font-medium transition-colors" 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={{ style={{
background: 'var(--primary)', backgroundColor: selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'var(--accent)' : 'var(--card-bg)',
color: 'var(--primary-foreground)', borderColor: selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'var(--primary)' : 'var(--card-border)'
border: 'none'
}} }}
> >
Connect Solana Wallet <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> </button>
</div> </div>
</div>
}
{/* 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>
) : ( ) : (
<SolanaConnect onConnect={(walletState) => setSolanaWalletState(walletState)} /> <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>
)}
</div>
{/* Step 3: URL Input */}
<div className="mb-8 p-6 rounded-lg" style={{ <div className="mb-8 p-6 rounded-lg" style={{
background: 'var(--muted-light)', background: 'var(--muted-light)',
borderLeft: '4px solid var(--primary)', borderLeft: '4px solid var(--primary)',
opacity: solanaWalletState.connected ? '1' : '0.6' opacity: (connected && isWalletCompatible) ? '1' : '0.6'
}}> }}>
<h2 className="text-lg font-semibold mb-4 flex items-center"> <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 Enter URL to Deploy
</h2> </h2>
<URLForm <URLForm
onSubmit={handleUrlSubmit} onSubmit={handleUrlSubmit}
disabled={!solanaWalletState.connected || status === 'creating'} disabled={!connected || !isWalletCompatible || isFetchingChainGenesisHash || status === 'creating' || Boolean(incorrectChainWarning)}
/> />
</div> </div>
{status !== 'idle' && ( {status !== 'idle' && (
<div className="p-6 rounded-lg" style={{ background: 'var(--muted-light)', borderLeft: '4px solid var(--primary)' }}> <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"> <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 Deployment Status
</h2> </h2>
<StatusDisplay <StatusDisplay
@ -162,21 +338,18 @@ export default function Home() {
dns={dns || undefined} dns={dns || undefined}
appName={appName || undefined} appName={appName || undefined}
repoUrl={repoUrl || undefined} repoUrl={repoUrl || undefined}
commitHash={commitHash || undefined}
shortCommitHash={shortCommitHash || undefined}
error={error || undefined} error={error || undefined}
/> />
</div> </div>
)} )}
</div> </div>
{showPaymentModal && url && solanaWalletState.connected && ( {showPaymentModal && url && connected && publicKey && selectedPaymentMethod && (
<PaymentModal <PaymentModal
isOpen={showPaymentModal} isOpen={showPaymentModal}
onClose={handleClosePaymentModal} onClose={handleClosePaymentModal}
url={url} url={url}
onPaymentComplete={handlePaymentComplete} onPaymentComplete={handlePaymentComplete}
walletState={solanaWalletState}
/> />
)} )}
</main> </main>

View File

@ -1,66 +1,150 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useState, useEffect } from 'react';
import BN from 'bn.js'; import assert from 'assert';
// import assert from 'assert';
import { clusterApiUrl, Connection } from '@solana/web3.js'; import { Connection } from '@solana/web3.js';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { sendSolanaTokenPayment } from '@/services/solana'; import { sendSolanaPayment } from '@/services/solana';
import { PaymentModalProps } from '@/types'; 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(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;
export default function PaymentModal({ export default function PaymentModal({
isOpen, isOpen,
onClose, onClose,
url, url,
onPaymentComplete, onPaymentComplete,
walletState,
}: PaymentModalProps) { }: PaymentModalProps) {
const { selectedPaymentMethod: paymentMethod } = usePaymentMethod();
const { connection: solanaConnection } = useConnection();
const { wallet, publicKey } = useWallet();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
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);
const connection = useMemo(() => new Connection(clusterApiUrl("mainnet-beta")), []); useEffect(() => {
// const connection = useMemo(() => new Connection(SOLANA_RPC_URL), []) const getDeploymentCostInfo = async () => {
const cost = await getCostOfDeployment();
// Get configuration from environment variables directly setDeploymentCost(cost);
const amount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!);
const divisor = useMemo(() => {
const decimalsEnv = process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS;
const decimals = parseInt(decimalsEnv!, 10);
if (isNaN(decimals)) {
console.warn("Invalid NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS; defaulting to 6.");
return 1e6;
} }
return 10 ** decimals;
getDeploymentCostInfo();
}, []); }, []);
const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS; 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 () => { 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); setLoading(true);
setError(''); setError('');
try { try {
const tokenAmount = new BN(amount); const paymentRequest: PaymentRequest = {
const result = await sendSolanaTokenPayment(connection, walletState.publicKey!, tokenAmount, walletState.walletType!); 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) { if (result.success && result.transactionSignature) {
onPaymentComplete(result.transactionSignature); onPaymentComplete(result.transactionSignature, paymentMethod);
} else { } else {
setError(result.error || `${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment failed. Please try again.`); setError(result.error || 'Payment failed. Please try again.');
} }
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : 'Payment failed. Please try again.'); setError(error instanceof Error ? error.message : 'Payment failed. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [connection, walletState, amount, onPaymentComplete]); }, [paymentMethod, tokenAmount, loadingPrice, wallet, solanaConnection, publicKey, onPaymentComplete]);
if (!isOpen) return null; const getPaymentAmountDisplay = () => {
if (loadingPrice) {
return 'Loading...'
};
return tokenAmount > 0 ? `${(tokenAmount / Math.pow(10, tokenDecimals)).toFixed(6)}` : '0';
};
if (!isOpen || !paymentMethod) return null;
return ( 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="fixed inset-0 flex items-center justify-center p-4 z-50" style={{ background: 'rgba(15, 23, 42, 0.75)' }}>
@ -68,7 +152,7 @@ export default function PaymentModal({
style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}> style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
<div className="p-6 border-b" style={{ borderColor: 'var(--card-border)' }}> <div className="p-6 border-b" style={{ borderColor: 'var(--card-border)' }}>
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}> <h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>
Complete {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Payment Complete Payment
</h2> </h2>
</div> </div>
@ -80,23 +164,48 @@ export default function PaymentModal({
</div> </div>
</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> <div>
<p className="text-sm mb-2 font-medium" style={{ color: 'var(--muted)' }}>Recipient Address:</p> <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)' }}> <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> </div>
<div> <div>
<label htmlFor="amount" className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
Amount ({process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}) Payment Amount
</label> </label>
<div className="space-y-3">
<div className="relative"> <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 <input
id="amount" type="text"
type="number" value={`$${deploymentCost ? deploymentCost.toPrecision(2) : null}`}
value={amount / divisor} disabled={true}
disabled={true} // Fixed amount for Solana tokens
className="w-full p-3 pr-12 rounded-md" className="w-full p-3 pr-12 rounded-md"
style={{ style={{
background: 'var(--card-bg)', background: 'var(--card-bg)',
@ -106,13 +215,54 @@ export default function PaymentModal({
}} }}
readOnly readOnly
/> />
)}
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> <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)' }}>{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}</span> <span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>USD</span>
</div> </div>
</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)' }}> <p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
Fixed amount required for deployment Token information fetched from <a className='text-blue-400 underline' href={`https://jup.ag/tokens/${mintAddress}`} target="_blank" rel="noopener noreferrer">Jupiter</a>
</p> </p>
)}
</div> </div>
{error && ( {error && (
@ -139,19 +289,21 @@ export default function PaymentModal({
onClick={handlePayment} onClick={handlePayment}
className="px-5 py-2 rounded-md flex items-center transition-colors" className="px-5 py-2 rounded-md flex items-center transition-colors"
style={{ style={{
backgroundColor: loading ? 'var(--muted)' : 'var(--primary)', backgroundColor: (loading || loadingPrice) ? 'var(--muted)' : 'var(--primary)',
color: 'var(--primary-foreground)', 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"> <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> <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> <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> </svg>
)} )}
{loading ? 'Processing...' : 'Pay with Solana Wallet'} {loadingPrice ? 'Loading Price...' :
loading ? 'Processing...' :
'Pay with Solana Wallet'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,148 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { connectSolanaWallet, disconnectSolanaWallet, checkSolanaWalletConnection } from '@/services/solana';
import { SolanaWalletType, SolanaWalletState } from '@/types';
interface SolanaConnectProps {
onConnect: (walletState: SolanaWalletState) => void;
}
export default function SolanaConnect({ onConnect }: SolanaConnectProps) {
const [connecting, setConnecting] = useState(false);
const [walletState, setWalletState] = useState<SolanaWalletState>({
connected: false,
publicKey: null,
walletType: null
});
const handleConnect = async (walletType: SolanaWalletType) => {
setConnecting(true);
try {
const newWalletState = await connectSolanaWallet(walletType);
setWalletState(newWalletState);
onConnect(newWalletState);
} catch (error) {
console.error('Failed to connect to Solana wallet:', error);
alert(error instanceof Error ? error.message : 'Failed to connect wallet');
} finally {
setConnecting(false);
}
};
const handleDisconnect = async () => {
if (walletState.walletType) {
try {
await disconnectSolanaWallet(walletState.walletType);
const disconnectedState = {
connected: false,
publicKey: null,
walletType: null
};
setWalletState(disconnectedState);
onConnect(disconnectedState);
} catch (error) {
console.error('Failed to disconnect wallet:', error);
}
}
};
useEffect(() => {
// Check for auto-connection on page load
const checkConnection = () => {
if (typeof window !== 'undefined') {
// Check Phantom
if (window.phantom?.solana && checkSolanaWalletConnection('phantom')) {
handleConnect('phantom');
return;
}
// Check Solflare
if (window.solflare && checkSolanaWalletConnection('solflare')) {
handleConnect('solflare');
return;
}
}
};
checkConnection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex flex-col items-center p-4 rounded-lg">
{walletState.connected ? (
<div className="flex flex-col items-center w-full">
<div className="flex items-center mb-2">
<span className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: 'var(--success)' }}></span>
<p className="font-medium" style={{ color: 'var(--success)' }}>
Connected ({walletState.walletType})
</p>
</div>
<div className="w-full p-3 rounded-md mb-3" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
<p className="text-sm font-mono break-all text-center">{walletState.publicKey}</p>
</div>
<button
onClick={handleDisconnect}
className="px-4 py-2 rounded-md text-sm transition-colors"
style={{
backgroundColor: 'var(--muted)',
color: 'var(--foreground)',
border: '1px solid var(--input-border)'
}}
>
Disconnect
</button>
</div>
) : (
<div className="w-full space-y-3">
<button
onClick={() => handleConnect('phantom')}
disabled={connecting || !window.phantom?.solana}
className="px-6 py-3 rounded-md w-full transition-colors disabled:opacity-50"
style={{
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
color: 'var(--primary-foreground)',
}}
>
<div className="flex items-center justify-center">
{connecting && (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{connecting ? 'Connecting...' : 'Connect Phantom Wallet'}
</div>
</button>
<button
onClick={() => handleConnect('solflare')}
disabled={connecting || !window.solflare}
className="px-6 py-3 rounded-md w-full transition-colors disabled:opacity-50"
style={{
backgroundColor: connecting ? 'var(--muted)' : 'var(--primary)',
color: 'var(--primary-foreground)',
}}
>
<div className="flex items-center justify-center">
{connecting && (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{connecting ? 'Connecting...' : 'Connect Solflare Wallet'}
</div>
</button>
{!window.phantom?.solana && !window.solflare && (
<p className="text-sm text-center" style={{ color: 'var(--muted)' }}>
Please install Phantom or Solflare wallet extension
</p>
)}
</div>
)}
</div>
);
}

View File

@ -9,8 +9,6 @@ interface StatusDisplayProps {
dns?: string; dns?: string;
appName?: string; appName?: string;
repoUrl?: string; repoUrl?: string;
commitHash?: string;
shortCommitHash?: string;
error?: string; error?: string;
} }
@ -23,8 +21,6 @@ export default function StatusDisplay({
dns, dns,
appName, appName,
repoUrl, repoUrl,
commitHash,
shortCommitHash,
error, error,
}: StatusDisplayProps) { }: StatusDisplayProps) {
// Get domain suffix from environment variable // Get domain suffix from environment variable

View File

@ -103,7 +103,7 @@ export default function URLForm({ onSubmit, disabled }: URLFormProps) {
opacity: (disabled || !url) ? '0.7' : '1', opacity: (disabled || !url) ? '0.7' : '1',
}} }}
> >
{disabled ? 'Connect Wallet First' : 'Deploy URL'} Deploy URL
</button> </button>
</form> </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,30 +1,43 @@
import { Registry, DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk'; import { DENOM as ALNT_DENOM, Registry } from '@cerc-io/registry-sdk';
import { GasPrice } from '@cosmjs/stargate';
import { RegistryConfig } from '../types'; import { RegistryConfig } from '../types';
import { GasPrice } from '@cosmjs/stargate';
let registryInstance: Registry | null = null; let registryInstance: Registry | null = null;
export const getRegistry = (): Registry => { export const getRegistry = (): Registry => {
if (!registryInstance) { if (!registryInstance) {
const config = getRegistryConfig(); const config = getClientRegistryConfig();
const gasPrice = GasPrice.fromString(config.fee.gasPrice + ALNT_DENOM); 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( registryInstance = new Registry(
config.gqlEndpoint, config.gqlEndpoint,
config.rpcEndpoint, config.rpcEndpoint,
{ chainId: config.chainId, gasPrice } {
chainId: config.chainId,
gasPrice,
}
); );
} }
return registryInstance; 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 => { export const getRegistryConfig = (): RegistryConfig => {
// Validate required environment variables // Validate required environment variables
const requiredEnvVars = [ const requiredEnvVars = [
'REGISTRY_CHAIN_ID', 'NEXT_PUBLIC_REGISTRY_CHAIN_ID',
'REGISTRY_GQL_ENDPOINT', 'NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT',
'REGISTRY_RPC_ENDPOINT', 'NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT',
'REGISTRY_BOND_ID', 'REGISTRY_BOND_ID',
'REGISTRY_AUTHORITY', 'REGISTRY_AUTHORITY',
'REGISTRY_USER_KEY' 'REGISTRY_USER_KEY'
@ -37,14 +50,11 @@ export const getRegistryConfig = (): RegistryConfig => {
} }
return { return {
chainId: process.env.REGISTRY_CHAIN_ID!, chainId: process.env.NEXT_PUBLIC_REGISTRY_CHAIN_ID!,
rpcEndpoint: process.env.REGISTRY_RPC_ENDPOINT!, rpcEndpoint: process.env.NEXT_PUBLIC_REGISTRY_RPC_ENDPOINT!,
gqlEndpoint: process.env.REGISTRY_GQL_ENDPOINT!, gqlEndpoint: process.env.NEXT_PUBLIC_REGISTRY_GQL_ENDPOINT!,
bondId: process.env.REGISTRY_BOND_ID!, bondId: process.env.REGISTRY_BOND_ID!,
authority: process.env.REGISTRY_AUTHORITY!, authority: process.env.REGISTRY_AUTHORITY!,
privateKey: process.env.REGISTRY_USER_KEY!, privateKey: process.env.REGISTRY_USER_KEY!,
fee: {
gasPrice: process.env.REGISTRY_GAS_PRICE || '0.001',
},
}; };
}; };

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

@ -86,7 +86,6 @@ const getAccount = async (accountPrivateKey: string): Promise<Account> => {
return account; return account;
} }
const sendTokensToAccount = async ( const sendTokensToAccount = async (
senderPrivateKey: string, senderPrivateKey: string,
receiverAddress: string, receiverAddress: string,

View File

@ -1,8 +1,18 @@
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 ( export const createApplicationDeploymentRequest = async (
url: string, url: string,
txHash: string, txHash: string,
paymentMethod: PaymentMethod,
): Promise<CreateRecordResponse> => { ): Promise<CreateRecordResponse> => {
try { try {
console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash} using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment`); console.log(`Creating deployment request for URL: ${url} with transaction: ${txHash} using ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} payment`);
@ -13,7 +23,7 @@ export const createApplicationDeploymentRequest = async (
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ url, txHash }), body: JSON.stringify({ url, txHash, paymentMethod }),
}); });
const result = await response.json(); const result = await response.json();
@ -49,3 +59,33 @@ export const createApplicationDeploymentRequest = async (
} }
}; };
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;
}

View File

@ -1,14 +1,16 @@
import assert from 'assert'; import assert from 'assert';
import BN from 'bn.js'; import BN from 'bn.js';
import { Connection, PublicKey, Transaction } from '@solana/web3.js'; import { Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import { import {
TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID,
createTransferInstruction, createTransferInstruction,
createAssociatedTokenAccountInstruction, createAssociatedTokenAccountInstruction,
ASSOCIATED_TOKEN_PROGRAM_ID ASSOCIATED_TOKEN_PROGRAM_ID,
getAssociatedTokenAddressSync
} from '@solana/spl-token'; } from '@solana/spl-token';
import { WalletAdapter } from '@solana/wallet-adapter-base';
import { SolanaPaymentResult, SolanaWalletType, SolanaWalletState } from '../types'; 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_MINT_ADDRESS, 'SOLANA_TOKEN_MINT_ADDRESS is required');
assert(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS, 'SOLANA_TOKEN_RECIPIENT_ADDRESS is required'); assert(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS, 'SOLANA_TOKEN_RECIPIENT_ADDRESS is required');
@ -16,59 +18,7 @@ assert(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS, 'SOLANA_TOKEN_REC
const TOKEN_MINT = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS; const TOKEN_MINT = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS;
const PAYMENT_RECEIVER_ADDRESS = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS; const PAYMENT_RECEIVER_ADDRESS = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
export const connectSolanaWallet = async (walletType: SolanaWalletType): Promise<SolanaWalletState> => { // Helper function to find associated token address
try {
if (walletType === 'phantom') {
if (!window.phantom?.solana) {
throw new Error('Phantom wallet not found. Please install Phantom browser extension.');
}
const response = await window.phantom.solana.connect();
return {
connected: true,
publicKey: response.publicKey.toString(),
walletType
};
} else if (walletType === 'solflare') {
if (!window.solflare) {
throw new Error('Solflare wallet not found. Please install Solflare browser extension.');
}
await window.solflare.connect();
const publicKey = window.solflare.publicKey?.toString();
if (!publicKey) {
throw new Error('Failed to get public key from Solflare wallet');
}
return {
connected: true,
publicKey,
walletType
};
}
throw new Error(`Unsupported wallet type: ${walletType}`);
} catch (error) {
console.error('Failed to connect to Solana wallet:', error);
throw error;
}
};
export const disconnectSolanaWallet = async (walletType: SolanaWalletType): Promise<void> => {
try {
let wallet = null;
if (walletType === 'phantom') {
wallet = window.phantom?.solana;
} else if (walletType === 'solflare') {
wallet = window.solflare;
}
if (wallet && wallet.disconnect) {
await wallet.disconnect();
}
} catch (error) {
console.error('Failed to disconnect Solana wallet:', error);
}
};
async function findAssociatedTokenAddress( async function findAssociatedTokenAddress(
walletAddress: PublicKey, walletAddress: PublicKey,
tokenMintAddress: PublicKey tokenMintAddress: PublicKey
@ -83,37 +33,90 @@ async function findAssociatedTokenAddress(
)[0]; )[0];
} }
interface WalletAdapter { // Send native GOR payment
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>; export async function sendNativeGorPayment(
} wallet: WalletAdapter,
export async function sendSolanaTokenPayment(
connection: Connection, connection: Connection,
walletPublicKey: string, walletPublicKey: string,
tokenAmount: BN, tokenAmount: BN
walletType: SolanaWalletType
): Promise<SolanaPaymentResult> { ): Promise<SolanaPaymentResult> {
try { try {
let wallet: WalletAdapter | null = null; if (!wallet.connected || !wallet.publicKey) {
throw new Error('Wallet not connected');
if (walletType === 'phantom') {
wallet = window.phantom?.solana || null;
} else if (walletType === 'solflare') {
wallet = window.solflare || null;
} }
if (!wallet) { const senderPublicKey = new PublicKey(walletPublicKey);
throw new Error(`${walletType} wallet not found`); 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 senderPublicKey = new PublicKey(walletPublicKey);
const mintPublicKey = new PublicKey(TOKEN_MINT); const mintPublicKey = new PublicKey(TOKEN_MINT);
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS); const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
console.log('Processing payment with keys:', { console.log('Processing SPL token payment:', {
sender: senderPublicKey.toBase58(), sender: senderPublicKey.toBase58(),
mint: mintPublicKey.toBase58(), mint: mintPublicKey.toBase58(),
receiver: receiverPublicKey.toBase58(), receiver: receiverPublicKey.toBase58(),
amount: tokenAmount.toString()
}); });
const senderATA = await findAssociatedTokenAddress( const senderATA = await findAssociatedTokenAddress(
@ -182,9 +185,9 @@ export async function sendSolanaTokenPayment(
transaction.recentBlockhash = latestBlockhash.blockhash; transaction.recentBlockhash = latestBlockhash.blockhash;
transaction.feePayer = senderPublicKey; transaction.feePayer = senderPublicKey;
console.log('Sending transaction...'); console.log('Sending SPL token transaction...');
const { signature } = await wallet.signAndSendTransaction(transaction); const signature = await wallet.sendTransaction(transaction, connection);
console.log('Transaction sent:', signature); console.log('SPL token transaction sent:', signature);
// Confirm transaction // Confirm transaction
const confirmation = await connection.confirmTransaction({ const confirmation = await connection.confirmTransaction({
@ -202,6 +205,37 @@ export async function sendSolanaTokenPayment(
success: true, success: true,
transactionSignature: signature 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) { } catch (error) {
console.error('Payment error:', error); console.error('Payment error:', error);
return { return {
@ -209,18 +243,25 @@ export async function sendSolanaTokenPayment(
error: error instanceof Error ? error.message : 'Payment failed' error: error instanceof Error ? error.message : 'Payment failed'
}; };
} }
}; }
// Helper function to check wallet connection status // Helper function to get recipient address based on payment method
export const checkSolanaWalletConnection = (walletType: SolanaWalletType): boolean => { export function getRecipientAddress(paymentMethod: PaymentMethod): string {
try { switch (paymentMethod) {
if (walletType === 'phantom') { case PaymentMethod.NAT_GOR:
return window.phantom?.solana?.isConnected || false; // For native GOR payments, use the direct recipient address
} else if (walletType === 'solflare') { return PAYMENT_RECEIVER_ADDRESS;
return window.solflare?.isConnected || false; 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}`);
} }
return false; }
} catch {
return false;
}
};

View File

@ -1,25 +1,8 @@
import { Transaction } from "@solana/web3.js"; // Payment method types
export enum PaymentMethod {
// extend the global Window interface to include Solana wallets NAT_GOR = 'nat-gor',
declare global { SPL_TOKEN = 'spl-token'
interface Window {
phantom?: {
solana?: {
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
connect(): Promise<{ publicKey: { toString(): string } }>;
disconnect(): Promise<void>;
isConnected: boolean;
};
};
solflare?: {
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
connect(): Promise<{ publicKey: { toString(): string } }>;
disconnect(): Promise<void>;
isConnected: boolean;
};
}
} }
export interface RegistryConfig { export interface RegistryConfig {
chainId: string; chainId: string;
rpcEndpoint: string; rpcEndpoint: string;
@ -27,9 +10,6 @@ export interface RegistryConfig {
bondId: string; bondId: string;
authority: string; authority: string;
privateKey: string; privateKey: string;
fee: {
gasPrice: string;
};
} }
export interface CreateRecordResponse { export interface CreateRecordResponse {
@ -45,7 +25,6 @@ export interface CreateRecordResponse {
message?: string; message?: string;
} }
export type SolanaWalletType = 'phantom' | 'solflare';
export interface SolanaPaymentResult { export interface SolanaPaymentResult {
success: boolean; success: boolean;
@ -57,14 +36,13 @@ export interface PaymentModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
url: string; url: string;
onPaymentComplete: (txHash: string) => void; onPaymentComplete: (txHash: string, paymentMethod: PaymentMethod) => void;
walletState: SolanaWalletState;
} }
export interface SolanaWalletState {
connected: boolean; export interface PaymentRequest {
publicKey: string | null; paymentMethod: PaymentMethod;
walletType: SolanaWalletType | null; amount: number; // in base units (lamports for native GOR, token base units for SPL)
} }
export interface LaconicTransferResult { export interface LaconicTransferResult {
@ -72,3 +50,11 @@ export interface LaconicTransferResult {
transactionHash?: string; transactionHash?: string;
error?: 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";

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

@ -0,0 +1,154 @@
import BN from 'bn.js';
import { Connection, ParsedInstruction, ParsedTransactionWithMeta, 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 (
parsedTx: ParsedTransactionWithMeta,
paymentMethod: PaymentMethod
): Promise<{ authority: string; amount: string; destination: string }> => {
if (!parsedTx) {
throw new Error('Transaction not found');
}
let transferInstruction: ParsedInstruction | PartiallyDecodedInstruction | undefined;
switch (paymentMethod) {
case PaymentMethod.NAT_GOR:
// Look for system program transfer instruction
transferInstruction = parsedTx.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 = parsedTx.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,
parsedTx: ParsedTransactionWithMeta,
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
if (!parsedTx) {
return {
valid: false,
reason: 'Transaction not found on Solana blockchain'
};
}
// Check if transaction was successful
if (parsedTx.meta?.err) {
return {
valid: false,
reason: `Transaction failed: ${JSON.stringify(parsedTx.meta.err)}`
};
}
// Check transaction timestamp (5-minute window)
const txTimestamp = parsedTx.blockTime ? new Date(parsedTx.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(parsedTx, 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

@ -1,132 +0,0 @@
import assert from 'assert';
import BN from 'bn.js';
import { Connection, PublicKey } from '@solana/web3.js';
import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token';
// Simplified transaction info extraction following reference implementation
const extractTxInfo = async (connection: Connection, transactionSignature: string): Promise<{ authority: string; amount: string }> => {
const result = await connection.getParsedTransaction(transactionSignature, 'confirmed');
if (!result) {
throw new Error('Transaction not found');
}
const transferInstruction = result.transaction.message.instructions.find(
(instr) => 'parsed' in instr && instr.programId.equals(TOKEN_PROGRAM_ID)
);
if (!transferInstruction || !('parsed' in transferInstruction)) {
throw new Error('Transfer instruction not found');
}
const { info: { amount, authority } } = transferInstruction.parsed;
return { authority, amount };
};
export const verifyUnusedSolanaPayment = async (
connection: Connection,
transactionSignature: string,
tokenAmount: BN
): Promise<{
valid: boolean,
reason?: string,
amount?: string,
sender?: string
}> => {
try {
// TODO: Check if provided signature is already used
// Fetch transaction details
const transactionResult = await connection.getParsedTransaction(transactionSignature, 'confirmed');
if (!transactionResult) {
return {
valid: false,
reason: 'Transaction not found on Solana blockchain'
};
}
// Check if transaction was successful
if (transactionResult.meta?.err) {
return {
valid: false,
reason: `Transaction failed: ${JSON.stringify(transactionResult.meta.err)}`
};
}
// Check transaction timestamp (5-minute window)
const txTimestamp = transactionResult.blockTime ? new Date(transactionResult.blockTime * 1000) : null;
if (!txTimestamp) {
return {
valid: false,
reason: 'Transaction timestamp not available'
};
}
const now = new Date();
const timeDiffMs = now.getTime() - txTimestamp.getTime();
const timeWindowMs = 5 * 60 * 1000; // 5 minutes
if (timeDiffMs > timeWindowMs) {
return {
valid: false,
reason: `Transaction is older than 5 minutes (${Math.round(timeDiffMs / 60000)} minutes old)`
};
}
// Extract transaction info using simplified approach
const { amount, authority } = await extractTxInfo(connection, transactionSignature);
// Verify amount using BN comparison like in reference
const transactionAmount = new BN(amount);
if (transactionAmount.lt(tokenAmount)) {
return {
valid: false,
reason: `Payment amount (${amount}) is less than required (${tokenAmount.toString()})`
};
}
const expectedTokenAccount = getAssociatedTokenAddressSync(
new PublicKey(process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!),
new PublicKey(process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS!),
// Allow recipient account address to be off the curve (i.e now having a private key)
true
);
// Verify recipient address by checking the transaction instructions
let foundValidTransfer = false;
for (const instruction of transactionResult.transaction.message.instructions) {
if ('parsed' in instruction && instruction.programId.equals(TOKEN_PROGRAM_ID)) {
const parsed = instruction.parsed;
if (parsed.type === 'transferChecked' || parsed.type === 'transfer') {
// Verify amount and recipient's associated token address
if (parsed.info.amount === amount && parsed.info.destination === expectedTokenAccount.toBase58() ) {
foundValidTransfer = true;
break;
}
}
}
}
if (!foundValidTransfer) {
return {
valid: false,
reason: 'Valid Solana token transfer not found in transaction'
};
}
return {
valid: true,
amount,
sender: authority
};
} catch (error) {
console.error('Error verifying Solana payment:', error);
return {
valid: false,
reason: `Failed to verify transaction: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
};