diff --git a/.env.example b/.env.example index 7107872..a45a019 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,7 @@ NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=71Jvq4Epe2FCJ7JFSF7jLXdNk1Wy4Bhqd9iL6bEFEL # Multisig address NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR -NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6 -NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400 # Approx. 5 USD +NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD=5 # Payment amount in USD # UI Configuration NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app diff --git a/CLAUDE.md b/CLAUDE.md index 9d42a8e..476ee7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ npm run lint # Run ESLint ### Tech Stack - **Frontend**: Next.js 15.3.1 with React 19 - **Styling**: TailwindCSS 4 -- **Blockchain**: +- **Blockchain**: - Solana Web3.js for payments - @cerc-io/registry-sdk for Laconic Registry - CosmJS for Cosmos blockchain interactions @@ -71,14 +71,13 @@ npm run lint # Run ESLint ### Client-side Variables (NEXT_PUBLIC_*) ```bash -NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com +NEXT_PUBLIC_SOLANA_RPC_URL=https://skilled-prettiest-seed.solana-mainnet.quiknode.pro/eeecfebd04e345f69f1900cc3483cbbfea02a158 NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS= NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS= NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR -NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6 -NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400 +NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD=5 NEXT_PUBLIC_DOMAIN_SUFFIX=.example.com -NEXT_PUBLIC_EXAMPLE_URL=https://github.com/cerc-io/laconic-registry-cli +NEXT_PUBLIC_EXAMPLE_URL=https://git.vdb.to/cerc-io/test-progressive-web-app ``` ### Server-side Variables diff --git a/src/app/api/registry/route.ts b/src/app/api/registry/route.ts index b1a5dee..2eeeb3f 100644 --- a/src/app/api/registry/route.ts +++ b/src/app/api/registry/route.ts @@ -10,10 +10,14 @@ import { DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk'; import { verifyUnusedSolanaPayment } from '@/utils/solanaVerify'; import { transferLNTTokens } from '@/services/laconicTransfer'; import { getRegistry, getRegistryConfig } from '@/config'; +import { getRequiredTokenInfo } from '@/services/jupiter-price'; assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required'); const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL; +// Allow 20% slippage due to price fluctuations +const ALLOWED_SLIPPAGE_FACTOR = 0.2 + // Use CAIP convention for chain ID: namespace + reference const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // Solana mainnet @@ -178,11 +182,23 @@ export async function POST(request: NextRequest) { // Verify Solana payment console.log('Step 0: Verifying Solana token payment...'); - const paymentAmount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!); - const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!); + const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!); + const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!; - const fullAmount = paymentAmount * Math.pow(10, decimals); - const tokenAmount = new BN(fullAmount); + // Calculate expected token amount based on current price + let expectedTokenAmount: number; + try { + const { requiredAmountInBaseUnits } = await getRequiredTokenInfo(targetUsdAmount, mintAddress); + expectedTokenAmount = Math.round(requiredAmountInBaseUnits - ALLOWED_SLIPPAGE_FACTOR * requiredAmountInBaseUnits); + } catch (error) { + console.error('Error calculating token amount:', error); + return NextResponse.json({ + status: 'error', + message: 'Unable to verify payment due to price calculation error' + }, { status: 500 }); + } + + const tokenAmount = new BN(expectedTokenAmount); const solanaPaymentResult = await verifyUnusedSolanaPayment(connection, txHash, tokenAmount); if (!solanaPaymentResult.valid) { @@ -211,7 +227,7 @@ export async function POST(request: NextRequest) { } 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 const requiredEnvVars = [ @@ -394,13 +410,13 @@ export async function POST(request: NextRequest) { note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`, repository: repoUrl, repository_ref: fullHash, - external_payment: { - chain_id: SOLANA_CHAIN_ID, - tx_hash: txHash, - pubkey: senderPublicKey - } }, - payment: finalTxHash, + external_payment: { + chain_id: SOLANA_CHAIN_ID, + tx_hash: txHash, + pubkey: senderPublicKey + }, + payment: laconicTxHash, }; console.log('Deployment request data:', deploymentRequestData); diff --git a/src/components/PaymentModal.tsx b/src/components/PaymentModal.tsx index 1753eb9..4a1bfdb 100644 --- a/src/components/PaymentModal.tsx +++ b/src/components/PaymentModal.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState, useEffect } from 'react'; import BN from 'bn.js'; import assert from 'assert'; import { Connection } from '@solana/web3.js'; import { sendSolanaTokenPayment } from '@/services/solana'; +import { getRequiredTokenInfo } from '@/services/jupiter-price'; import { PaymentModalProps } from '@/types'; assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required'); @@ -21,24 +22,53 @@ export default function PaymentModal({ }: PaymentModalProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [tokenAmount, setTokenAmount] = useState(0); + const [tokenDecimals, setTokenDecimals] = useState(6); // Default fallback + const [loadingPrice, setLoadingPrice] = useState(true); const connection = useMemo(() => new Connection(SOLANA_RPC_URL), []) - // Get configuration from environment variables directly - const amount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!); - const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!); - + // Get configuration from environment variables + const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!); + const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!; const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS; + // Fetch token amount based on USD price + useEffect(() => { + if (!isOpen) return; + + const fetchTokenAmount = async () => { + setLoadingPrice(true); + setError(''); + + try { + const {requiredAmountInBaseUnits, decimals} = await getRequiredTokenInfo(targetUsdAmount, mintAddress) + setTokenAmount(requiredAmountInBaseUnits); + setTokenDecimals(decimals); + } catch (error) { + console.error('Error fetching token price:', error); + setError('Unable to fetch current token price. Please try again.'); + } finally { + setLoadingPrice(false); + } + }; + + fetchTokenAmount(); + }, [isOpen, targetUsdAmount, mintAddress]); + const handlePayment = useCallback(async () => { + if (tokenAmount === 0 || loadingPrice) { + setError('Token amount not calculated yet. Please wait.'); + return; + } + setLoading(true); setError(''); try { - const fullAmount = amount * Math.pow(10, decimals); - const tokenAmount = new BN(fullAmount); + const tokenAmountBN = new BN(tokenAmount); - const result = await sendSolanaTokenPayment(connection, walletState.publicKey!, tokenAmount, walletState.walletType!); + const result = await sendSolanaTokenPayment(connection, walletState.publicKey!, tokenAmountBN, walletState.walletType!); if (result.success && result.transactionSignature) { onPaymentComplete(result.transactionSignature); @@ -50,7 +80,7 @@ export default function PaymentModal({ } finally { setLoading(false); } - }, [connection, walletState, amount, decimals, onPaymentComplete]); + }, [connection, walletState, tokenAmount, loadingPrice, onPaymentComplete]); if (!isOpen) return null; @@ -80,30 +110,66 @@ export default function PaymentModal({
-
diff --git a/src/services/jupiter-price.ts b/src/services/jupiter-price.ts new file mode 100644 index 0000000..5a20f7a --- /dev/null +++ b/src/services/jupiter-price.ts @@ -0,0 +1,67 @@ +interface JupiterPriceResponse { + [mintAddress: string]: { + usdPrice: number; + blockId: number; + decimals: number; + priceChange24h: number; + }; +} + +interface TokenPriceInfo { + usdPrice: number; + decimals: number; +} + +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 + */ +export async function getTokenInfo(mintAddress: string): Promise { + 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 { + const priceInfo = await getTokenInfo(mintAddress); + + // Calculate token amount needed + const tokenAmount = targetUsdAmount / priceInfo.usdPrice; + + // Convert to smallest units (considering decimals) + const amountInSmallestUnits = Math.round(tokenAmount * Math.pow(10, priceInfo.decimals)); + + return {requiredAmountInBaseUnits: amountInSmallestUnits, decimals: priceInfo.decimals}; +} diff --git a/src/services/laconicTransfer.ts b/src/services/laconic-transfer.ts similarity index 100% rename from src/services/laconicTransfer.ts rename to src/services/laconic-transfer.ts diff --git a/src/utils/solanaVerify.ts b/src/utils/solana-verify.ts similarity index 100% rename from src/utils/solanaVerify.ts rename to src/utils/solana-verify.ts