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..3f27ed4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,8 +75,7 @@ NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com 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 ``` diff --git a/src/app/api/registry/route.ts b/src/app/api/registry/route.ts index b1a5dee..0f1838f 100644 --- a/src/app/api/registry/route.ts +++ b/src/app/api/registry/route.ts @@ -10,6 +10,7 @@ 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 { calculateTokenAmount } from '@/services/jupiterPrice'; assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required'); const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL; @@ -178,11 +179,22 @@ 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 { + expectedTokenAmount = await calculateTokenAmount(targetUsdAmount, mintAddress); + } 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) { @@ -394,11 +406,11 @@ 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 - } + }, + external_payment: { + chain_id: SOLANA_CHAIN_ID, + tx_hash: txHash, + pubkey: senderPublicKey }, payment: finalTxHash, }; diff --git a/src/components/PaymentModal.tsx b/src/components/PaymentModal.tsx index 1753eb9..70107fc 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 { calculateTokenAmount, getTokenPrice } from '@/services/jupiterPrice'; import { PaymentModalProps } from '@/types'; assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required'); @@ -21,24 +22,56 @@ 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 [amount, priceInfo] = await Promise.all([ + calculateTokenAmount(targetUsdAmount, mintAddress), + getTokenPrice(mintAddress) + ]); + setTokenAmount(amount); + setTokenDecimals(priceInfo.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 +83,7 @@ export default function PaymentModal({ } finally { setLoading(false); } - }, [connection, walletState, amount, decimals, onPaymentComplete]); + }, [connection, walletState, tokenAmount, loadingPrice, onPaymentComplete]); if (!isOpen) return null; @@ -80,30 +113,66 @@ export default function PaymentModal({
-
diff --git a/src/services/jupiterPrice.ts b/src/services/jupiterPrice.ts new file mode 100644 index 0000000..4120e3f --- /dev/null +++ b/src/services/jupiterPrice.ts @@ -0,0 +1,78 @@ +interface JupiterPriceResponse { + [mintAddress: string]: { + usdPrice: number; + blockId: number; + decimals: number; + priceChange24h: number; + }; +} + +interface TokenPriceInfo { + usdPrice: 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 getTokenPrice(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 + */ +export async function calculateTokenAmount(targetUsdAmount: number, mintAddress: string): Promise { + const priceInfo = await getTokenPrice(mintAddress); + + // Calculate token amount needed + const tokenAmount = targetUsdAmount / priceInfo.usdPrice; + + // Convert to smallest units (considering decimals) + const amountInSmallestUnits = Math.ceil(tokenAmount * Math.pow(10, priceInfo.decimals)); + + return amountInSmallestUnits; +} + +/** + * Gets the current USD value of a token amount + * @param tokenAmount Token amount in smallest units + * @param mintAddress The Solana token mint address + * @returns USD value of the token amount + */ +export async function getTokenValueInUSD(tokenAmount: number, mintAddress: string): Promise { + const priceInfo = await getTokenPrice(mintAddress); + + // Convert from smallest units to token units + const actualTokens = tokenAmount / Math.pow(10, priceInfo.decimals); + + // Calculate USD value + return actualTokens * priceInfo.usdPrice; +} \ No newline at end of file