Pull required amount of tokens using jupiter API (#2)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949 Co-authored-by: Shreerang Kale <shreerangkale@gmail.com> Reviewed-on: #2 Co-authored-by: shreerang <shreerang@noreply.git.vdb.to> Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
This commit is contained in:
parent
acabd4569f
commit
ac9d4ea8eb
@ -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
|
||||
|
@ -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=<SPL_TOKEN_MINT>
|
||||
NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS=<RECIPIENT_WALLET>
|
||||
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
|
||||
|
@ -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);
|
||||
|
@ -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<number>(0);
|
||||
const [tokenDecimals, setTokenDecimals] = useState<number>(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({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="amount" className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
|
||||
Amount ({process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL})
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--foreground)' }}>
|
||||
Payment Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
value={amount}
|
||||
disabled={true} // Fixed amount for Solana tokens
|
||||
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
|
||||
/>
|
||||
<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 className="space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={`$${targetUsdAmount}`}
|
||||
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
|
||||
/>
|
||||
<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)' }}>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>
|
||||
Fetching 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>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
|
||||
Fixed amount required for deployment
|
||||
Token information fetched from <a className='text-blue-400 underline' href={`https://jup.ag/tokens/${process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS}`}>Jupiter</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -131,19 +197,19 @@ export default function PaymentModal({
|
||||
onClick={handlePayment}
|
||||
className="px-5 py-2 rounded-md flex items-center transition-colors"
|
||||
style={{
|
||||
backgroundColor: loading ? 'var(--muted)' : 'var(--primary)',
|
||||
backgroundColor: (loading || loadingPrice) ? 'var(--muted)' : 'var(--primary)',
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
{loading ? 'Processing...' : 'Pay with Solana Wallet'}
|
||||
{loadingPrice ? 'Loading Price...' : loading ? 'Processing...' : 'Pay with Solana Wallet'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
67
src/services/jupiter-price.ts
Normal file
67
src/services/jupiter-price.ts
Normal file
@ -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<TokenPriceInfo> {
|
||||
try {
|
||||
const response = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mintAddress}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Jupiter API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: JupiterPriceResponse = await response.json();
|
||||
|
||||
if (!data[mintAddress]) {
|
||||
throw new Error(`Price not found for token: ${mintAddress}`);
|
||||
}
|
||||
|
||||
const tokenInfo = data[mintAddress];
|
||||
|
||||
return {
|
||||
usdPrice: tokenInfo.usdPrice,
|
||||
decimals: tokenInfo.decimals
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching token price from Jupiter:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the token amount needed for a given USD price
|
||||
* @param targetUsdAmount The target amount in USD
|
||||
* @param mintAddress The Solana token mint address
|
||||
* @returns The token amount (in smallest units) needed and token decimals
|
||||
*/
|
||||
export async function getRequiredTokenInfo(targetUsdAmount: number, mintAddress: string): Promise<RequiredTokenInfo> {
|
||||
const priceInfo = await getTokenInfo(mintAddress);
|
||||
|
||||
// Calculate token amount needed
|
||||
const tokenAmount = targetUsdAmount / priceInfo.usdPrice;
|
||||
|
||||
// Convert to smallest units (considering decimals)
|
||||
const amountInSmallestUnits = Math.round(tokenAmount * Math.pow(10, priceInfo.decimals));
|
||||
|
||||
return {requiredAmountInBaseUnits: amountInSmallestUnits, decimals: priceInfo.decimals};
|
||||
}
|
Loading…
Reference in New Issue
Block a user