Pull token amount based on USD using jupiter API

This commit is contained in:
Shreerang Kale 2025-07-22 10:11:13 +05:30
parent acabd4569f
commit 527d8431bd
5 changed files with 204 additions and 47 deletions

View File

@ -8,8 +8,7 @@ NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS=71Jvq4Epe2FCJ7JFSF7jLXdNk1Wy4Bhqd9iL6bEFEL
# Multisig address # Multisig address
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY
NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL=GOR
NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6 NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD=5 # Payment amount in USD
NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400 # Approx. 5 USD
# 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

@ -75,8 +75,7 @@ NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
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
NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS=6 NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD=5
NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT=400
NEXT_PUBLIC_DOMAIN_SUFFIX=.example.com NEXT_PUBLIC_DOMAIN_SUFFIX=.example.com
NEXT_PUBLIC_EXAMPLE_URL=https://github.com/cerc-io/laconic-registry-cli NEXT_PUBLIC_EXAMPLE_URL=https://github.com/cerc-io/laconic-registry-cli
``` ```

View File

@ -10,6 +10,7 @@ import { DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
import { verifyUnusedSolanaPayment } from '@/utils/solanaVerify'; import { verifyUnusedSolanaPayment } from '@/utils/solanaVerify';
import { transferLNTTokens } from '@/services/laconicTransfer'; import { transferLNTTokens } from '@/services/laconicTransfer';
import { getRegistry, getRegistryConfig } from '@/config'; import { getRegistry, getRegistryConfig } from '@/config';
import { calculateTokenAmount } from '@/services/jupiterPrice';
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');
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL; const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
@ -178,11 +179,22 @@ export async function POST(request: NextRequest) {
// Verify Solana payment // Verify Solana payment
console.log('Step 0: Verifying Solana token payment...'); console.log('Step 0: Verifying Solana token payment...');
const paymentAmount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!); const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!);
const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!); const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!;
const fullAmount = paymentAmount * Math.pow(10, decimals); // Calculate expected token amount based on current price
const tokenAmount = new BN(fullAmount); 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); const solanaPaymentResult = await verifyUnusedSolanaPayment(connection, txHash, tokenAmount);
if (!solanaPaymentResult.valid) { 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}`, note: `Added via ${process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}-Deploy @ ${timestamp}`,
repository: repoUrl, repository: repoUrl,
repository_ref: fullHash, repository_ref: fullHash,
external_payment: { },
chain_id: SOLANA_CHAIN_ID, external_payment: {
tx_hash: txHash, chain_id: SOLANA_CHAIN_ID,
pubkey: senderPublicKey tx_hash: txHash,
} pubkey: senderPublicKey
}, },
payment: finalTxHash, payment: finalTxHash,
}; };

View File

@ -1,12 +1,13 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState, useEffect } from 'react';
import BN from 'bn.js'; 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 { sendSolanaTokenPayment } from '@/services/solana'; import { sendSolanaTokenPayment } from '@/services/solana';
import { calculateTokenAmount, getTokenPrice } from '@/services/jupiterPrice';
import { PaymentModalProps } from '@/types'; import { PaymentModalProps } 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');
@ -21,24 +22,56 @@ export default function PaymentModal({
}: PaymentModalProps) { }: PaymentModalProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [tokenAmount, setTokenAmount] = useState<number>(0);
const [tokenDecimals, setTokenDecimals] = useState<number>(6); // Default fallback
const [loadingPrice, setLoadingPrice] = useState(true);
const connection = useMemo(() => new Connection(SOLANA_RPC_URL), []) const connection = useMemo(() => new Connection(SOLANA_RPC_URL), [])
// Get configuration from environment variables directly // Get configuration from environment variables
const amount = parseInt(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT!); const targetUsdAmount = parseFloat(process.env.NEXT_PUBLIC_SOLANA_PAYMENT_AMOUNT_USD!);
const decimals = parseInt(process.env.NEXT_PUBLIC_SOLANA_TOKEN_DECIMALS!); const mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!;
const recipientAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_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 () => { const handlePayment = useCallback(async () => {
if (tokenAmount === 0 || loadingPrice) {
setError('Token amount not calculated yet. Please wait.');
return;
}
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const fullAmount = amount * Math.pow(10, decimals); const tokenAmountBN = new BN(tokenAmount);
const tokenAmount = new BN(fullAmount);
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) { if (result.success && result.transactionSignature) {
onPaymentComplete(result.transactionSignature); onPaymentComplete(result.transactionSignature);
@ -50,7 +83,7 @@ export default function PaymentModal({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [connection, walletState, amount, decimals, onPaymentComplete]); }, [connection, walletState, tokenAmount, loadingPrice, onPaymentComplete]);
if (!isOpen) return null; if (!isOpen) return null;
@ -80,30 +113,66 @@ export default function PaymentModal({
</div> </div>
<div> <div>
<label htmlFor="amount" className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
Amount ({process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}) Payment Amount
</label> </label>
<div className="relative"> <div className="space-y-3">
<input <div className="relative">
id="amount" <input
type="number" type="text"
value={amount} value={`$${targetUsdAmount}`}
disabled={true} // Fixed amount for Solana tokens disabled={true}
className="w-full p-3 pr-12 rounded-md" className="w-full p-3 pr-12 rounded-md"
style={{ style={{
background: 'var(--card-bg)', background: 'var(--card-bg)',
border: '1px solid var(--input-border)', border: '1px solid var(--input-border)',
color: 'var(--foreground)', color: 'var(--foreground)',
opacity: '0.7' opacity: '0.7'
}} }}
readOnly readOnly
/> />
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> <div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}</span> <span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>USD</span>
</div>
</div>
<div className="relative">
{loadingPrice ? (
<div className="w-full p-3 rounded-md flex items-center" style={{
background: 'var(--muted-light)',
border: '1px solid var(--input-border)',
color: 'var(--muted)'
}}>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Calculating token amount...
</div>
) : (
<input
type="text"
value={tokenAmount > 0 ? (tokenAmount / Math.pow(10, tokenDecimals)).toFixed(6) : '0'}
disabled={true}
className="w-full p-3 pr-12 rounded-md"
style={{
background: 'var(--card-bg)',
border: '1px solid var(--input-border)',
color: 'var(--foreground)',
opacity: '0.7'
}}
readOnly
/>
)}
{!loadingPrice && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL}</span>
</div>
)}
</div> </div>
</div> </div>
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}> <p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
Fixed amount required for deployment Token amount calculated from current market price
</p> </p>
</div> </div>
@ -131,19 +200,19 @@ export default function PaymentModal({
onClick={handlePayment} onClick={handlePayment}
className="px-5 py-2 rounded-md flex items-center transition-colors" className="px-5 py-2 rounded-md flex items-center transition-colors"
style={{ style={{
backgroundColor: loading ? 'var(--muted)' : 'var(--primary)', backgroundColor: (loading || loadingPrice) ? 'var(--muted)' : 'var(--primary)',
color: 'var(--primary-foreground)', color: 'var(--primary-foreground)',
opacity: loading ? '0.8' : '1' opacity: (loading || loadingPrice) ? '0.8' : '1'
}} }}
disabled={loading} disabled={loading || loadingPrice}
> >
{loading && ( {(loading || loadingPrice) && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
)} )}
{loading ? 'Processing...' : 'Pay with Solana Wallet'} {loadingPrice ? 'Loading Price...' : loading ? 'Processing...' : 'Pay with Solana Wallet'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -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<TokenPriceInfo> {
try {
const response = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mintAddress}`);
if (!response.ok) {
throw new Error(`Jupiter API error: ${response.status} ${response.statusText}`);
}
const data: JupiterPriceResponse = await response.json();
if (!data[mintAddress]) {
throw new Error(`Price not found for token: ${mintAddress}`);
}
const tokenInfo = data[mintAddress];
return {
usdPrice: tokenInfo.usdPrice,
decimals: tokenInfo.decimals
};
} catch (error) {
console.error('Error fetching token price from Jupiter:', error);
throw error;
}
}
/**
* Calculates the token amount needed for a given USD price
* @param targetUsdAmount The target amount in USD
* @param mintAddress The Solana token mint address
* @returns The token amount (in smallest units) needed
*/
export async function calculateTokenAmount(targetUsdAmount: number, mintAddress: string): Promise<number> {
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<number> {
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;
}