Add option to accept payments in Gorbagana chain native tokens #5

Merged
nabarun merged 15 commits from sk-gorbagana-chain into main 2025-07-25 04:57:41 +00:00
23 changed files with 5394 additions and 646 deletions

View File

@ -1,14 +1,18 @@
# 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 or use browser wallet
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_PAYMENT_AMOUNT_USD=5 # Payment amount in USD NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD=5
# 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

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

121
CLAUDE.md
View File

@ -2,7 +2,7 @@
## Project Overview ## Project Overview
**GOR Deploy** is a Next.js application that bridges Solana blockchain token payments with Laconic Registry deployments. Users pay with GOR tokens (or any configurable Solana SPL token) and the app creates deployment records in the Laconic Registry for frontend applications. **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 ## Quick Commands
@ -26,12 +26,16 @@ npm run lint # Run ESLint
- Solana Web3.js for payments - Solana Web3.js for payments
- @cerc-io/registry-sdk for Laconic Registry - @cerc-io/registry-sdk for Laconic Registry
- CosmJS for Cosmos blockchain interactions - CosmJS for Cosmos blockchain interactions
- **Wallet Integration**: Phantom & Solflare - **Wallet Integration**: Phantom, Solflare & Backpack with context-based filtering
- **Language**: TypeScript - **Language**: TypeScript
### Key Dependencies ### Key Dependencies
- `@solana/web3.js` - Solana blockchain interactions - `@solana/web3.js` - Solana blockchain interactions
- `@solana/spl-token` - SPL token handling - `@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 - `@cerc-io/registry-sdk` - Laconic Registry operations
- `@cosmjs/stargate` - Cosmos blockchain operations - `@cosmjs/stargate` - Cosmos blockchain operations
- `axios` - HTTP requests for GitHub API - `axios` - HTTP requests for GitHub API
@ -40,38 +44,58 @@ npm run lint # Run ESLint
## Application Flow ## Application Flow
1. **Wallet Connection**: User connects Phantom or Solflare wallet 1. **Payment Method Selection**: User chooses between native GOR or SPL token payment
2. **URL Input**: User enters repository URL to deploy 2. **Wallet Connection**: Compatible wallets are filtered based on payment method
3. **Payment**: Fixed amount payment in GOR/SPL tokens to configured recipient 3. **URL Input**: User enters repository URL to deploy (enabled after wallet connection)
4. **Verification**: Server verifies Solana transaction (5-minute window) 4. **Payment**: Fixed amount payment in native GOR or GOR SPL tokens to configured recipient
5. **LNT Transfer**: Server transfers LNT tokens for registry fees 5. **Verification**: Server verifies Solana transaction (5-minute window)
6. **Registry Records**: Creates ApplicationRecord and ApplicationDeploymentRequest 6. **LNT Transfer**: Server transfers LNT tokens for registry fees
7. **Name Mapping**: Sets LRN mappings in Laconic Registry 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 ## Key Files and Components
### Frontend Components ### Frontend Components
- `src/app/page.tsx` - Main application page with 3-step flow - `src/app/page.tsx` - Main application page with 4-step flow (Payment Method → Wallet → URL → Status)
- `src/components/PaymentModal.tsx` - Solana payment interface - `src/components/PaymentModal.tsx` - Solana payment interface with pre-selected payment method
- `src/components/SolanaConnect.tsx` - Wallet connection UI - `src/components/WalletProviders.tsx` - Context-aware wallet provider with filtering
- `src/components/URLForm.tsx` - URL input form - `src/components/URLForm.tsx` - URL input form
- `src/components/StatusDisplay.tsx` - Deployment status display - `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 ### Services and Utils
- `src/services/solana.ts` - Solana wallet and payment operations - `src/services/solana.ts` - Unified wallet adapter integration for payments
- `src/services/registry.ts` - Client-side registry service wrapper - `src/services/registry.ts` - Client-side registry service wrapper
- `src/services/laconic-transfer.ts` - LNT token transfer logic - `src/services/laconic-transfer.ts` - LNT token transfer logic
- `src/utils/solana-verify.ts` - Payment verification with replay protection - `src/utils/solana-verify.ts` - Payment verification with replay protection
- `src/config/index.ts` - Registry configuration management - `src/config/index.ts` - Registry configuration management
- `src/constants/payments.ts` - Payment method types and configuration
### API Routes ### API Routes
- `src/app/api/registry/route.ts` - Main server-side registry operations - `src/app/api/registry/route.ts` - Main server-side registry operations with dual payment support
## Environment Configuration ## Environment Configuration
### Client-side Variables (NEXT_PUBLIC_*) ### Client-side Variables (NEXT_PUBLIC_*)
```bash ```bash
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_GORBAGANA_RPC_URL=https://rpc.gorbagana.wtf
NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=<SPL_TOKEN_MINT> NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=<SPL_TOKEN_MINT>
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=<RECIPIENT_WALLET> NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=<RECIPIENT_WALLET>
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
@ -95,20 +119,29 @@ DEPLOYER_LRN=<DEPLOYER_LRN>
## Code Patterns and Conventions ## Code Patterns and Conventions
### State Management ### State Management
- Uses React `useState` for component state - Uses React Context (`PaymentMethodContext`) for global payment method state
- No global state management library - React `useState` for component-level state
- Dynamic imports to avoid SSR issues with browser APIs - 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 ### Error Handling
- Comprehensive try-catch blocks in all async operations - Comprehensive try-catch blocks in all async operations
- User-friendly error messages in UI - User-friendly error messages in UI
- Server-side error logging with detailed context - Server-side error logging with detailed context
- Wallet compatibility validation with clear feedback
### Security Best Practices ### Security Best Practices
- Environment variables for sensitive data - Environment variables for sensitive data
- Transaction verification with replay protection - Transaction verification with replay protection
- Proper SPL token account creation and validation - Proper SPL token account creation and validation
- Private key never exposed to client-side - Private key never exposed to client-side
- Wallet signature verification via adapters
## Development Workflow ## Development Workflow
@ -117,11 +150,18 @@ DEPLOYER_LRN=<DEPLOYER_LRN>
2. **Services**: Add to `src/services/` 2. **Services**: Add to `src/services/`
3. **API Routes**: Add to `src/app/api/` 3. **API Routes**: Add to `src/app/api/`
4. **Types**: Update `src/types/index.ts` 4. **Types**: Update `src/types/index.ts`
5. **Payment Methods**: Update `src/constants/payments.ts`
### Payment Integration ### Payment Integration
- All payment logic in `src/services/solana.ts` - All payment logic unified in `src/services/solana.ts`
- Verification logic in `src/utils/solana-verify.ts` - Verification logic in `src/utils/solana-verify.ts`
- Transaction signing via wallet adapters - 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 Integration
- Registry operations in `src/app/api/registry/route.ts` - Registry operations in `src/app/api/registry/route.ts`
@ -147,8 +187,9 @@ Format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
### Solana Payment Verification ### Solana Payment Verification
- 5-minute transaction window - 5-minute transaction window
- Amount verification against configured payment - Amount verification against configured payment
- Recipient address validation - Recipient address validation (different for native vs SPL)
- Replay attack protection (TODO: implement used transaction tracking) - Replay attack protection (TODO: implement used transaction tracking)
- Payment method-specific validation logic
### Laconic Registry Operations ### Laconic Registry Operations
1. **ApplicationRecord**: Repository metadata and commit info 1. **ApplicationRecord**: Repository metadata and commit info
@ -158,20 +199,45 @@ Format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
## Common Development Tasks ## Common Development Tasks
### Adding New Wallet Support ### Adding New Wallet Support
1. Update `SolanaWalletType` in `src/types/index.ts` 1. Install wallet adapter package: `npm install @solana/wallet-adapter-{wallet-name}`
2. Add wallet logic to `src/services/solana.ts` 2. Add to `src/components/WalletProviders.tsx` wallets array
3. Update `SolanaConnect.tsx` component 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 ### Modifying Payment Verification
- Update logic in `src/utils/solana-verify.ts` - Update logic in `src/utils/solana-verify.ts`
- Consider replay protection requirements - Consider replay protection requirements
- Test with various transaction types - Test with various transaction types
- Handle both native GOR and SPL token verification
### Registry Schema Changes ### Registry Schema Changes
- Update record structures in `src/app/api/registry/route.ts` - Update record structures in `src/app/api/registry/route.ts`
- Ensure compatibility with Laconic Registry schema - Ensure compatibility with Laconic Registry schema
- Test with registry validation - 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 ## Deployment Considerations
### Production Setup ### Production Setup
@ -179,6 +245,7 @@ Format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
- HTTPS required for wallet connections - HTTPS required for wallet connections
- Consider rate limiting for API routes - Consider rate limiting for API routes
- Monitor gas fees and LNT transfer costs - Monitor gas fees and LNT transfer costs
- Ensure both RPC endpoints are reliable
### Docker Deployment ### Docker Deployment
- Dockerfile provided in project root - Dockerfile provided in project root
@ -189,15 +256,18 @@ Format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
### Common Issues ### Common Issues
- **Wallet connection fails**: Check HTTPS and wallet extension - **Wallet connection fails**: Check HTTPS and wallet extension
- **Transaction verification fails**: Check RPC URL and transaction timing - **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 - **Registry errors**: Verify all REGISTRY_* environment variables
- **LNT transfer fails**: Check private key balance and permissions - **LNT transfer fails**: Check private key balance and permissions
- **SPL token price errors**: Verify Jupiter API connectivity
### Debug Tools ### Debug Tools
- Browser console for client-side debugging - Browser console for client-side debugging
- Server logs for API route debugging - Server logs for API route debugging
- Solana Explorer for transaction verification - Solana Explorer for transaction verification
- Registry CLI for direct registry interactions - Registry CLI for direct registry interactions
- Payment method context state in React DevTools
## Testing Strategy ## Testing Strategy
@ -205,6 +275,8 @@ Format: `lrn://{authority}/applications/{app-name}-{commit-hash}`
- Use testnet/devnet for development - Use testnet/devnet for development
- Test transaction verification edge cases - Test transaction verification edge cases
- Verify registry record creation and name mappings - 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 ## Related Projects
@ -219,7 +291,8 @@ This application references:
- Transaction replay protection implemented - Transaction replay protection implemented
- Input validation on all user inputs - Input validation on all user inputs
- Secure RPC endpoint usage - Secure RPC endpoint usage
- Wallet signature verification - Wallet signature verification via adapters
- Payment method validation before processing
## Future Enhancements ## Future Enhancements
@ -228,3 +301,5 @@ This application references:
- Enhanced error recovery and retry logic - Enhanced error recovery and retry logic
- Monitoring and analytics integration - Monitoring and analytics integration
- Multi-chain payment support - Multi-chain payment support
- Additional wallet adapter integrations
- Advanced payment method configurations

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;

4693
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,14 @@
"@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",
@ -26,7 +31,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

@ -10,10 +10,15 @@ import { DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
import { verifyUnusedSolanaPayment } from '@/utils/solana-verify'; import { verifyUnusedSolanaPayment } from '@/utils/solana-verify';
import { transferLNTTokens } from '@/services/laconic-transfer'; import { transferLNTTokens } from '@/services/laconic-transfer';
import { getRegistry, getRegistryConfig } from '@/config'; import { getRegistry, getRegistryConfig } from '@/config';
import { getRequiredTokenInfo } from '@/services/jupiter-price'; import { getRequiredTokenInfo, RequiredTokenInfo } from '@/services/jupiter-price';
import { IS_NAT_GOR_TRANSFER_ENABLED, SOLANA_GOR_MINT_ADDRESS } from '@/constants/payments';
import { PaymentMethod } from '@/types';
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 // Allow 20% slippage due to price fluctuations
const ALLOWED_SLIPPAGE_FACTOR = 0.2 const ALLOWED_SLIPPAGE_FACTOR = 0.2
@ -134,21 +139,43 @@ 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;
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) {
@ -160,15 +187,23 @@ export async function POST(request: NextRequest) {
} }
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) {
@ -180,36 +215,54 @@ export async function POST(request: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
// Verify Solana payment // Verify Solana payment based on method
console.log('Step 0: Verifying Solana token payment...'); console.log(`Step 0: Verifying Solana ${paymentMethod} payment...`);
// Calculate expected token amount based on current price
let requiredTokenInfo: RequiredTokenInfo;
const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!); const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!);
const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!; const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!;
// Calculate expected token amount based on current price
let expectedTokenAmount: number;
try { try {
const { requiredAmountInBaseUnits } = await getRequiredTokenInfo(targetUsdAmount, mintAddress); if (paymentMethod === PaymentMethod.NAT_GOR) {
expectedTokenAmount = Math.round(requiredAmountInBaseUnits - ALLOWED_SLIPPAGE_FACTOR * requiredAmountInBaseUnits); 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) { } catch (error) {
console.error('Error calculating token amount:', error); console.error('Error calculating expected amount:', error);
return NextResponse.json({ return NextResponse.json({
status: 'error', status: 'error',
message: 'Unable to verify payment due to price calculation error' message: 'Unable to verify payment due to price calculation error'
}, { status: 500 }); }, { status: 500 });
} }
const tokenAmount = new BN(expectedTokenAmount); const requiredAmountInBaseUnits = requiredTokenInfo.requiredAmountInBaseUnits;
const solanaPaymentResult = await verifyUnusedSolanaPayment(connection, txHash, tokenAmount); const expectedTokenAmount = Math.round(requiredAmountInBaseUnits - ALLOWED_SLIPPAGE_FACTOR * requiredAmountInBaseUnits);
const solanaPaymentResult = await verifyUnusedSolanaPayment(
connection,
txHash,
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
}); });

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 />
{children} <PaymentMethodProvider>
<WalletProviders>
{children}
</WalletProviders>
</PaymentMethodProvider>
</body> </body>
</html> </html>
); );

View File

@ -1,24 +1,28 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState, useRef } 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 { IS_NAT_GOR_TRANSFER_ENABLED, PAYMENT_METHOD_LABELS } from '@/constants/payments';
import { usePaymentMethod } from '@/contexts/PaymentMethodContext';
import { PaymentMethod } from '@/types';
// 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 });
const GORBAGANA_GENESIS_HASH = '533uBE9RRquhTBqEX58oV52FdTTsReMdAvaUvP6hNjsn';
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 +33,70 @@ 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 [incorrectChainWarining, setIncorrectChainWarining] = useState<string | null>(null);
const handleConnectWallet = () => { useEffect(() => {
setShowWalletConnection(true); if (!IS_NAT_GOR_TRANSFER_ENABLED) {
}; setSelectedPaymentMethod(PaymentMethod.SPL_TOKEN);
}
}, [setSelectedPaymentMethod]);
useEffect(() => {
if (!wallet || wallet.adapter.name !== BackpackWalletName || selectedPaymentMethod !== PaymentMethod.NAT_GOR) {
return;
}
const warnOnIncorrectChain = async () => {
// @ts-expect-error: backpack exists on window object
const genesisHash = await window.backpack.solana.connection.getGenesisHash();
if (genesisHash !== GORBAGANA_GENESIS_HASH) {
setIncorrectChainWarining("WARNING: Unsupported chain selected in wallet. Please switch to Gorbagana chain")
}
}
warnOnIncorrectChain();
}, [wallet, selectedPaymentMethod]);
// 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 = () => {
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 !isBackpack; // Only non-Backpack wallets for SPL tokens
}
};
const handlePaymentComplete = useCallback(async (hash: string, paymentMethod: PaymentMethod) => {
if (!publicKey || !url) {
return return
} }
@ -53,7 +106,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 +125,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 +134,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);
@ -96,61 +143,149 @@ export default function Home() {
return ( return (
<main className="min-h-screen flex flex-col items-center justify-center p-6"> <main className="min-h-screen flex flex-col items-center justify-center p-6">
<div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }} <div style={{ background: 'var(--card-bg)', borderColor: 'var(--card-border)' }}
className="max-w-xl w-full p-8 rounded-xl shadow-lg border"> className="max-w-xl w-full p-8 rounded-xl shadow-lg border">
<h1 className="text-2xl font-bold mb-8 text-center" style={{ color: 'var(--foreground)' }}> <h1 className="text-2xl font-bold mb-8 text-center" style={{ color: 'var(--foreground)' }}>
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">
<button
onClick={() => setSelectedPaymentMethod(PaymentMethod.NAT_GOR)}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
}`}
style={{
backgroundColor: selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'var(--accent)' : 'var(--card-bg)',
borderColor: selectedPaymentMethod === PaymentMethod.NAT_GOR ? 'var(--primary)' : 'var(--card-border)'
}}
>
<div className="text-left">
<h3 className="font-semibold text-lg mb-2">GOR (native)</h3>
<p className="text-xs mt-1" style={{ color: 'var(--muted-foreground)' }}>
Compatible with: Backpack
</p>
</div>
</button>
<button
onClick={() => setSelectedPaymentMethod(PaymentMethod.SPL_TOKEN)}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedPaymentMethod === PaymentMethod.SPL_TOKEN ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
}`}
style={{
backgroundColor: selectedPaymentMethod === PaymentMethod.SPL_TOKEN ? 'var(--accent)' : 'var(--card-bg)',
borderColor: selectedPaymentMethod === PaymentMethod.SPL_TOKEN ? 'var(--primary)' : 'var(--card-border)'
}}
>
<div className="text-left">
<h3 className="font-semibold text-lg mb-2">{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Token</h3>
<p className="text-xs mt-1" style={{ color: 'var(--muted-foreground)' }}>
Compatible with: Phantom, Solflare
</p>
</div>
</button>
</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>
) : (
<div className="text-center"> <div className="text-center">
<p className="mb-4" style={{ color: 'var(--muted-foreground)' }}> <p className="mb-4" style={{ color: 'var(--muted-foreground)' }}>
Payment method: <span className="font-semibold" style={{ color: 'var(--foreground)' }}> Selected: <span className="font-semibold" style={{ color: 'var(--foreground)' }}>
{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} (Solana) {PAYMENT_METHOD_LABELS[selectedPaymentMethod]}
</span> </span>
</p> </p>
<button {connected && publicKey ? (
onClick={handleConnectWallet} <div className="flex flex-col items-center space-y-3">
className="px-6 py-2 rounded-lg font-medium transition-colors" <div className="flex items-center">
style={{ <span className="w-3 h-3 rounded-full mr-2" style={{
background: 'var(--primary)', backgroundColor: isWalletCompatible() ? 'var(--success)' : 'var(--destructive)'
color: 'var(--primary-foreground)', }}></span>
border: 'none' <p className="font-medium" style={{
}} color: isWalletCompatible() ? 'var(--success)' : 'var(--destructive)'
> }}>
Connect Solana Wallet {isWalletCompatible() ? 'Compatible' : 'Incompatible'} Wallet ({wallet?.adapter.name})
</button> </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>
)}
{incorrectChainWarining && (
<p className="text-sm text-amber-400">
{incorrectChainWarining}
</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>
) : (
<SolanaConnect onConnect={(walletState) => setSolanaWalletState(walletState)} />
)} )}
</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() || status === 'creating'}
/> />
</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 +297,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,64 +1,101 @@
'use client'; 'use client';
import { useCallback, useMemo, useState, useEffect } from 'react'; import { useCallback, useState, useEffect } from 'react';
import BN from 'bn.js';
import assert from 'assert'; import assert from 'assert';
import { 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 { getRequiredTokenInfo } from '@/services/jupiter-price'; import { getRequiredTokenInfo, RequiredTokenInfo } from '@/services/jupiter-price';
import { PaymentModalProps } from '@/types'; import { PaymentMethod, PaymentModalProps, PaymentRequest } from '@/types';
import { IS_NAT_GOR_TRANSFER_ENABLED, PAYMENT_METHOD_LABELS, SOLANA_GOR_MINT_ADDRESS } from '@/constants/payments';
import { usePaymentMethod } from '@/contexts/PaymentMethodContext';
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 [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [tokenAmount, setTokenAmount] = useState<number>(0); const [tokenAmount, setTokenAmount] = useState<number>(0);
const [tokenDecimals, setTokenDecimals] = useState<number>(6); // Default fallback const [tokenDecimals, setTokenDecimals] = useState<number>(6); // Default fallback
const [loadingPrice, setLoadingPrice] = useState(true); const [loadingPrice, setLoadingPrice] = useState(false);
const connection = useMemo(() => new Connection(SOLANA_RPC_URL), []) const { wallet, publicKey } = useWallet();
// Get configuration from environment variables // Get configuration from environment variables
const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!); const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!);
const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!; const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!;
const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS; const tokenSymbol = process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL;
// Fetch token amount based on USD price // Fetch payment amount based on USD price for both payment methods
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen || !paymentMethod) {
setLoadingPrice(false);
return;
}
const fetchTokenAmount = async () => { const fetchPaymentAmount = async () => {
setLoadingPrice(true); setLoadingPrice(true);
setError(''); setError('');
try { try {
const {requiredAmountInBaseUnits, decimals} = await getRequiredTokenInfo(targetUsdAmount, mintAddress) let requiredTokenInfo: RequiredTokenInfo
setTokenAmount(requiredAmountInBaseUnits); if (paymentMethod === PaymentMethod.NAT_GOR) {
setTokenDecimals(decimals); // Fetch native GOR amount using solana GOR token price
requiredTokenInfo = await getRequiredTokenInfo(targetUsdAmount, SOLANA_GOR_MINT_ADDRESS);
} else if (paymentMethod === PaymentMethod.SPL_TOKEN) {
// Fetch SPL token amount using token mint price
requiredTokenInfo = await getRequiredTokenInfo(targetUsdAmount, mintAddress);
} else {
setError('Invalid payment method');
return;
}
setTokenAmount(requiredTokenInfo.requiredAmountInBaseUnits);
setTokenDecimals(requiredTokenInfo.decimals);
} catch (error) { } catch (error) {
console.error('Error fetching token price:', error); console.error('Error fetching payment amount:', error);
setError('Unable to fetch current token price. Please try again.'); setError('Unable to fetch current payment amount. Please try again.');
} finally { } finally {
setLoadingPrice(false); setLoadingPrice(false);
} }
}; };
fetchTokenAmount(); fetchPaymentAmount();
}, [isOpen, targetUsdAmount, mintAddress]); }, [isOpen, paymentMethod, targetUsdAmount, 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) { if (tokenAmount === 0 || loadingPrice) {
setError('Token amount not calculated yet. Please wait.'); setError('Payment amount not ready. Please wait.');
return;
}
if (!wallet || !wallet.adapter) {
setError('Wallet not connected.');
return; return;
} }
@ -66,31 +103,50 @@ export default function PaymentModal({
setError(''); setError('');
try { try {
const tokenAmountBN = new BN(tokenAmount); const paymentRequest: PaymentRequest = {
paymentMethod: paymentMethod,
amount: tokenAmount,
};
const result = await sendSolanaTokenPayment(connection, walletState.publicKey!, tokenAmountBN, walletState.walletType!); // 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, tokenAmount, loadingPrice, 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)' }}>
<div className="max-w-md w-full rounded-xl shadow-xl animate-appear" <div className="max-w-md w-full rounded-xl shadow-xl animate-appear"
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>
@ -102,10 +158,23 @@ 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>
@ -117,7 +186,7 @@ export default function PaymentModal({
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
value={`$${targetUsdAmount}`} value={targetUsdAmount}
disabled={true} disabled={true}
className="w-full p-3 pr-12 rounded-md" className="w-full p-3 pr-12 rounded-md"
style={{ style={{
@ -149,9 +218,9 @@ export default function PaymentModal({
) : ( ) : (
<input <input
type="text" type="text"
value={tokenAmount > 0 ? (tokenAmount / Math.pow(10, tokenDecimals)).toFixed(6) : '0'} value={getPaymentAmountDisplay()}
disabled={true} disabled={true}
className="w-full p-3 pr-12 rounded-md" className="w-full p-3 pr-20 rounded-md"
style={{ style={{
background: 'var(--card-bg)', background: 'var(--card-bg)',
border: '1px solid var(--input-border)', border: '1px solid var(--input-border)',
@ -161,16 +230,20 @@ export default function PaymentModal({
readOnly readOnly
/> />
)} )}
{!loadingPrice && ( {!(paymentMethod === PaymentMethod.SPL_TOKEN && loadingPrice) && (
<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)' }}>
{paymentMethod === PaymentMethod.NAT_GOR ? 'GOR (native)' : tokenSymbol}
</span>
</div> </div>
)} )}
</div> </div>
</div> </div>
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}> {paymentMethod === PaymentMethod.SPL_TOKEN && (
Token information fetched from <a className='text-blue-400 underline' href={`https://jup.ag/tokens/${process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS}`}>Jupiter</a> <p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
</p> Token information fetched from <a className='text-blue-400 underline' href={`https://jup.ag/tokens/${mintAddress}`} target="_blank" rel="noopener noreferrer">Jupiter</a>
</p>
)}
</div> </div>
{error && ( {error && (
@ -209,7 +282,9 @@ export default function PaymentModal({
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <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>
)} )}
{loadingPrice ? 'Loading Price...' : 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
@ -59,7 +55,7 @@ export default function StatusDisplay({
return ( return (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold" <span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold"
style={{ backgroundColor: styles.bg, color: styles.color }}> style={{ backgroundColor: styles.bg, color: styles.color }}>
{styles.text} {styles.text}
</span> </span>
); );
@ -131,9 +127,9 @@ export default function StatusDisplay({
{lrn && <InfoItem label="Laconic Resource Name (LRN)" value={lrn} />} {lrn && <InfoItem label="Laconic Resource Name (LRN)" value={lrn} />}
{dns && ( {dns && (
domainSuffix domainSuffix
? <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={`https://${dns}.${domainSuffix}`} /> ? <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={`https://${dns}.${domainSuffix}`} />
: <InfoItem label="Please set domain suffix" value="" /> : <InfoItem label="Please set domain suffix" value="" />
)} )}
</div> </div>
)} )}

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

11
src/constants/payments.ts Normal file
View File

@ -0,0 +1,11 @@
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';
export const IS_NAT_GOR_TRANSFER_ENABLED = process.env.NEXT_PUBLIC_ENABLE_NATIVE_GOR_TRANSFER === "true";

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

@ -12,7 +12,7 @@ interface TokenPriceInfo {
decimals: number; decimals: number;
} }
interface RequiredTokenInfo { export interface RequiredTokenInfo {
requiredAmountInBaseUnits: number; requiredAmountInBaseUnits: number;
decimals: number; decimals: number;
} }
@ -22,7 +22,7 @@ interface RequiredTokenInfo {
* @param mintAddress The Solana token mint address * @param mintAddress The Solana token mint address
* @returns Token price information including USD price and decimals * @returns Token price information including USD price and decimals
*/ */
export async function getTokenInfo(mintAddress: string): Promise<TokenPriceInfo> { async function getTokenInfo(mintAddress: string): Promise<TokenPriceInfo> {
try { try {
const response = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mintAddress}`); const response = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mintAddress}`);
@ -61,7 +61,7 @@ export async function getRequiredTokenInfo(targetUsdAmount: number, mintAddress:
const tokenAmount = targetUsdAmount / priceInfo.usdPrice; const tokenAmount = targetUsdAmount / priceInfo.usdPrice;
// Convert to smallest units (considering decimals) // Convert to smallest units (considering decimals)
const amountInSmallestUnits = Math.round(tokenAmount * Math.pow(10, priceInfo.decimals)); const requiredAmountInBaseUnits = Math.round(tokenAmount * Math.pow(10, priceInfo.decimals));
return {requiredAmountInBaseUnits: amountInSmallestUnits, decimals: priceInfo.decimals}; return {requiredAmountInBaseUnits, decimals: priceInfo.decimals};
} }

View File

@ -50,9 +50,9 @@ export const transferLNTTokens = async (): Promise<LaconicTransferResult> => {
// Send tokens from prefilled account to payment address // Send tokens from prefilled account to payment address
const transferResult = await sendTokensToAccount( const transferResult = await sendTokensToAccount(
registryConfig.privateKey, registryConfig.privateKey,
paymentAddress, paymentAddress,
deployerMinPayment deployerMinPayment
); );
console.log('LNT transfer result:', transferResult); console.log('LNT transfer result:', transferResult);

View File

@ -1,8 +1,10 @@
import { CreateRecordResponse } from '../types'; import { CreateRecordResponse } from '../types';
import { PaymentMethod } from '../types';
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 +15,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();

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
return false; const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
} catch { const mintPublicKey = new PublicKey(TOKEN_MINT);
return false; const associatedTokenAddress = getAssociatedTokenAddressSync(
mintPublicKey,
receiverPublicKey,
true // Allow off-curve addresses
);
return associatedTokenAddress.toBase58();
default:
throw new Error(`Unsupported payment method: ${paymentMethod}`);
} }
}; }

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;
@ -45,7 +28,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 +39,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 {

View File

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