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

Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: #5
Co-authored-by: shreerang <shreerang@noreply.git.vdb.to>
Co-committed-by: shreerang <shreerang@noreply.git.vdb.to>
This commit was merged in pull request #5.
This commit is contained in:
2025-07-25 04:57:40 +00:00
committed by nabarun
co-authored by Shreerang Kale
parent b4c6b0aa4e
commit fbe4eed31d
23 changed files with 5394 additions and 646 deletions
+75 -22
View File
@@ -10,10 +10,15 @@ import { DENOM as ALNT_DENOM } from '@cerc-io/registry-sdk';
import { verifyUnusedSolanaPayment } from '@/utils/solana-verify';
import { transferLNTTokens } from '@/services/laconic-transfer';
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(!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;
// Allow 20% slippage due to price fluctuations
const ALLOWED_SLIPPAGE_FACTOR = 0.2
@@ -134,21 +139,43 @@ export const registryTransactionWithRetry = async (
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) {
try {
if (!connection) {
connection = new Connection(SOLANA_RPC_URL);
}
// First check if the request body is valid JSON
let url, txHash, senderPublicKey;
let url, txHash, senderPublicKey, paymentMethod;
let connection: Connection;
try {
const body = await request.json();
url = body.url;
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');
if (!tx) {
@@ -160,15 +187,23 @@ export async function POST(request: NextRequest) {
}
const signerKeys = tx.transaction.message.accountKeys
.filter(k => k.signer)
.map(k => k.pubkey.toBase58());
.filter(k => k.signer)
.map(k => k.pubkey.toBase58());
senderPublicKey = signerKeys[0];
if (!url || !txHash) {
if (!url || !txHash || !paymentMethod) {
return NextResponse.json({
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 });
}
} catch (error) {
@@ -180,36 +215,54 @@ export async function POST(request: NextRequest) {
}, { status: 400 });
}
// Verify Solana payment
console.log('Step 0: Verifying Solana token payment...');
// Verify Solana payment based on method
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 mintAddress = process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!;
// 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);
if (paymentMethod === PaymentMethod.NAT_GOR) {
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) {
console.error('Error calculating token amount:', error);
console.error('Error calculating expected 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 requiredAmountInBaseUnits = requiredTokenInfo.requiredAmountInBaseUnits;
const expectedTokenAmount = Math.round(requiredAmountInBaseUnits - ALLOWED_SLIPPAGE_FACTOR * requiredAmountInBaseUnits);
const solanaPaymentResult = await verifyUnusedSolanaPayment(
connection,
txHash,
new BN(expectedTokenAmount),
paymentMethod,
);
if (!solanaPaymentResult.valid) {
console.error('Solana token payment verification failed:', solanaPaymentResult.reason);
console.error(`Solana ${paymentMethod} payment verification failed:`, solanaPaymentResult.reason);
return NextResponse.json({
status: 'error',
message: `Payment verification failed: ${solanaPaymentResult.reason}`
}, { status: 400 });
}
console.log('Solana token payment verified successfully:', {
console.log(`Solana ${paymentMethod} payment verified successfully:`, {
amount: solanaPaymentResult.amount,
sender: solanaPaymentResult.sender
});
+8 -1
View File
@@ -3,6 +3,9 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import ErrorBoundaryWrapper from "../components/ErrorBoundaryWrapper";
import WalletProviders from "../components/WalletProviders";
import { PaymentMethodProvider } from "../contexts/PaymentMethodContext";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -30,7 +33,11 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ErrorBoundaryWrapper />
{children}
<PaymentMethodProvider>
<WalletProviders>
{children}
</WalletProviders>
</PaymentMethodProvider>
</body>
</html>
);
+186 -54
View File
@@ -1,24 +1,28 @@
'use client';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
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 StatusDisplay from '@/components/StatusDisplay';
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
const SolanaConnect = dynamic(() => import('@/components/SolanaConnect'), { ssr: false });
const PaymentModal = dynamic(() => import('@/components/PaymentModal'), { ssr: false });
const GORBAGANA_GENESIS_HASH = '533uBE9RRquhTBqEX58oV52FdTTsReMdAvaUvP6hNjsn';
export default function Home() {
const [showWalletConnection, setShowWalletConnection] = useState(false);
const [solanaWalletState, setSolanaWalletState] = useState<SolanaWalletState>({
connected: false,
publicKey: null,
walletType: null
});
const { wallet, connected, publicKey, disconnect } = useWallet();
const { selectedPaymentMethod, setSelectedPaymentMethod } = usePaymentMethod();
const [url, setUrl] = useState<string | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false);
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 [appName, setAppName] = 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 [incorrectChainWarining, setIncorrectChainWarining] = useState<string | null>(null);
const handleConnectWallet = () => {
setShowWalletConnection(true);
};
useEffect(() => {
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) => {
setUrl(submittedUrl);
setShowPaymentModal(true);
};
const handlePaymentComplete = useCallback(async (hash: string) => {
if (!solanaWalletState.publicKey || !url) {
// Helper function to check if current wallet is compatible with selected payment method
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
}
@@ -53,7 +106,7 @@ export default function Home() {
try {
// 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') {
setRecordId(result.id);
@@ -72,12 +125,6 @@ export default function Home() {
if (result.repoUrl) {
setRepoUrl(result.repoUrl);
}
if (result.commitHash) {
setCommitHash(result.commitHash);
}
if (result.shortCommitHash) {
setShortCommitHash(result.shortCommitHash);
}
setStatus('success');
} else {
setStatus('error');
@@ -87,7 +134,7 @@ export default function Home() {
setStatus('error');
setError(error instanceof Error ? error.message : 'An unknown error occurred');
}
}, [solanaWalletState, url])
}, [publicKey, url])
const handleClosePaymentModal = () => {
setShowPaymentModal(false);
@@ -96,61 +143,149 @@ export default function Home() {
return (
<main className="min-h-screen flex flex-col items-center justify-center p-6">
<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)' }}>
Deploy Frontends with {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} + Laconic
</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)' }}>
<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)' }}>1</span>
Connect Your Wallet
Choose Payment Method
</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">
<p className="mb-4" style={{ color: 'var(--muted-foreground)' }}>
Payment method: <span className="font-semibold" style={{ color: 'var(--foreground)' }}>
{process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} (Solana)
Selected: <span className="font-semibold" style={{ color: 'var(--foreground)' }}>
{PAYMENT_METHOD_LABELS[selectedPaymentMethod]}
</span>
</p>
<button
onClick={handleConnectWallet}
className="px-6 py-2 rounded-lg font-medium transition-colors"
style={{
background: 'var(--primary)',
color: 'var(--primary-foreground)',
border: 'none'
}}
>
Connect Solana Wallet
</button>
{connected && publicKey ? (
<div className="flex flex-col items-center space-y-3">
<div className="flex items-center">
<span className="w-3 h-3 rounded-full mr-2" style={{
backgroundColor: isWalletCompatible() ? 'var(--success)' : 'var(--destructive)'
}}></span>
<p className="font-medium" style={{
color: isWalletCompatible() ? 'var(--success)' : 'var(--destructive)'
}}>
{isWalletCompatible() ? 'Compatible' : 'Incompatible'} Wallet ({wallet?.adapter.name})
</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>
) : (
<SolanaConnect onConnect={(walletState) => setSolanaWalletState(walletState)} />
)}
</div>
{/* Step 3: URL Input */}
<div className="mb-8 p-6 rounded-lg" style={{
background: 'var(--muted-light)',
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">
<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
</h2>
<URLForm
onSubmit={handleUrlSubmit}
disabled={!solanaWalletState.connected || status === 'creating'}
disabled={!connected || !isWalletCompatible() || status === 'creating'}
/>
</div>
{status !== 'idle' && (
<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">
<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
</h2>
<StatusDisplay
@@ -162,21 +297,18 @@ export default function Home() {
dns={dns || undefined}
appName={appName || undefined}
repoUrl={repoUrl || undefined}
commitHash={commitHash || undefined}
shortCommitHash={shortCommitHash || undefined}
error={error || undefined}
/>
</div>
)}
</div>
{showPaymentModal && url && solanaWalletState.connected && (
{showPaymentModal && url && connected && publicKey && selectedPaymentMethod && (
<PaymentModal
isOpen={showPaymentModal}
onClose={handleClosePaymentModal}
url={url}
onPaymentComplete={handlePaymentComplete}
walletState={solanaWalletState}
/>
)}
</main>
+2 -2
View File
@@ -18,7 +18,7 @@ export default function ErrorBoundary() {
nextjs-portal {
display: none !important;
}
/* Specifically target the error popup button */
[data-nextjs-dialog-overlay],
[data-nextjs-toast],
@@ -35,4 +35,4 @@ export default function ErrorBoundary() {
}, []);
return null;
}
}
+1 -1
View File
@@ -7,4 +7,4 @@ const ErrorBoundary = dynamic(() => import('./ErrorBoundary'), { ssr: false });
export default function ErrorBoundaryWrapper() {
return <ErrorBoundary />;
}
}
+115 -40
View File
@@ -1,64 +1,101 @@
'use client';
import { useCallback, useMemo, useState, useEffect } from 'react';
import BN from 'bn.js';
import { useCallback, useState, useEffect } from 'react';
import assert from 'assert';
import { Connection } from '@solana/web3.js';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { sendSolanaTokenPayment } from '@/services/solana';
import { getRequiredTokenInfo } from '@/services/jupiter-price';
import { PaymentModalProps } from '@/types';
import { sendSolanaPayment } from '@/services/solana';
import { getRequiredTokenInfo, RequiredTokenInfo } from '@/services/jupiter-price';
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');
const SOLANA_RPC_URL = process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
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 GORBAGANA_RPC_URL = process.env.NEXT_PUBLIC_GORBAGANA_RPC_URL;
export default function PaymentModal({
isOpen,
onClose,
url,
onPaymentComplete,
walletState,
}: PaymentModalProps) {
const { selectedPaymentMethod: paymentMethod } = usePaymentMethod();
const { connection: solanaConnection } = useConnection();
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 [loadingPrice, setLoadingPrice] = useState(false);
const connection = useMemo(() => new Connection(SOLANA_RPC_URL), [])
const { wallet, publicKey } = useWallet();
// 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;
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(() => {
if (!isOpen) return;
if (!isOpen || !paymentMethod) {
setLoadingPrice(false);
return;
}
const fetchTokenAmount = async () => {
const fetchPaymentAmount = async () => {
setLoadingPrice(true);
setError('');
try {
const {requiredAmountInBaseUnits, decimals} = await getRequiredTokenInfo(targetUsdAmount, mintAddress)
setTokenAmount(requiredAmountInBaseUnits);
setTokenDecimals(decimals);
let requiredTokenInfo: RequiredTokenInfo
if (paymentMethod === PaymentMethod.NAT_GOR) {
// 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) {
console.error('Error fetching token price:', error);
setError('Unable to fetch current token price. Please try again.');
console.error('Error fetching payment amount:', error);
setError('Unable to fetch current payment amount. Please try again.');
} finally {
setLoadingPrice(false);
}
};
fetchTokenAmount();
}, [isOpen, targetUsdAmount, mintAddress]);
fetchPaymentAmount();
}, [isOpen, paymentMethod, targetUsdAmount, mintAddress]);
// Initialize state when modal opens
useEffect(() => {
if (isOpen) {
setError('');
setTokenAmount(0);
}
}, [isOpen]);
const handlePayment = useCallback(async () => {
if (!paymentMethod) {
return;
}
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;
}
@@ -66,31 +103,50 @@ export default function PaymentModal({
setError('');
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) {
onPaymentComplete(result.transactionSignature);
onPaymentComplete(result.transactionSignature, paymentMethod);
} 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) {
setError(error instanceof Error ? error.message : 'Payment failed. Please try again.');
} finally {
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 (
<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"
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)' }}>
<h2 className="text-xl font-semibold" style={{ color: 'var(--foreground)' }}>
Complete {process.env.NEXT_PUBLIC_SOLANA_TOKEN_SYMBOL} Payment
Complete Payment
</h2>
</div>
@@ -102,10 +158,23 @@ export default function PaymentModal({
</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>
<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)' }}>
<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>
@@ -117,7 +186,7 @@ export default function PaymentModal({
<div className="relative">
<input
type="text"
value={`$${targetUsdAmount}`}
value={targetUsdAmount}
disabled={true}
className="w-full p-3 pr-12 rounded-md"
style={{
@@ -149,9 +218,9 @@ export default function PaymentModal({
) : (
<input
type="text"
value={tokenAmount > 0 ? (tokenAmount / Math.pow(10, tokenDecimals)).toFixed(6) : '0'}
value={getPaymentAmountDisplay()}
disabled={true}
className="w-full p-3 pr-12 rounded-md"
className="w-full p-3 pr-20 rounded-md"
style={{
background: 'var(--card-bg)',
border: '1px solid var(--input-border)',
@@ -161,16 +230,20 @@ export default function PaymentModal({
readOnly
/>
)}
{!loadingPrice && (
{!(paymentMethod === PaymentMethod.SPL_TOKEN && 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>
<span className="text-sm font-medium" style={{ color: 'var(--muted)' }}>
{paymentMethod === PaymentMethod.NAT_GOR ? 'GOR (native)' : tokenSymbol}
</span>
</div>
)}
</div>
</div>
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
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>
{paymentMethod === PaymentMethod.SPL_TOKEN && (
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
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>
{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>
</svg>
)}
{loadingPrice ? 'Loading Price...' : loading ? 'Processing...' : 'Pay with Solana Wallet'}
{loadingPrice ? 'Loading Price...' :
loading ? 'Processing...' :
'Pay with Solana Wallet'}
</button>
</div>
</div>
-148
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>
);
}
+4 -8
View File
@@ -9,8 +9,6 @@ interface StatusDisplayProps {
dns?: string;
appName?: string;
repoUrl?: string;
commitHash?: string;
shortCommitHash?: string;
error?: string;
}
@@ -23,8 +21,6 @@ export default function StatusDisplay({
dns,
appName,
repoUrl,
commitHash,
shortCommitHash,
error,
}: StatusDisplayProps) {
// Get domain suffix from environment variable
@@ -59,7 +55,7 @@ export default function StatusDisplay({
return (
<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}
</span>
);
@@ -131,9 +127,9 @@ export default function StatusDisplay({
{lrn && <InfoItem label="Laconic Resource Name (LRN)" value={lrn} />}
{dns && (
domainSuffix
? <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={`https://${dns}.${domainSuffix}`} />
: <InfoItem label="Please set domain suffix" value="" />
)}
? <InfoItem label="Deployment URL (ready in 2-3 minutes)" value={`https://${dns}.${domainSuffix}`} />
: <InfoItem label="Please set domain suffix" value="" />
)}
</div>
)}
+70
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
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";
+39
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;
}
+4 -4
View File
@@ -12,7 +12,7 @@ interface TokenPriceInfo {
decimals: number;
}
interface RequiredTokenInfo {
export interface RequiredTokenInfo {
requiredAmountInBaseUnits: number;
decimals: number;
}
@@ -22,7 +22,7 @@ interface RequiredTokenInfo {
* @param mintAddress The Solana token mint address
* @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 {
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;
// 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};
}
+3 -3
View File
@@ -50,9 +50,9 @@ export const transferLNTTokens = async (): Promise<LaconicTransferResult> => {
// Send tokens from prefilled account to payment address
const transferResult = await sendTokensToAccount(
registryConfig.privateKey,
paymentAddress,
deployerMinPayment
registryConfig.privateKey,
paymentAddress,
deployerMinPayment
);
console.log('LNT transfer result:', transferResult);
+3 -1
View File
@@ -1,8 +1,10 @@
import { CreateRecordResponse } from '../types';
import { PaymentMethod } from '../types';
export const createApplicationDeploymentRequest = async (
url: string,
txHash: string,
paymentMethod: PaymentMethod,
): Promise<CreateRecordResponse> => {
try {
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: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, txHash }),
body: JSON.stringify({ url, txHash, paymentMethod }),
});
const result = await response.json();
+129 -88
View File
@@ -1,14 +1,16 @@
import assert from 'assert';
import BN from 'bn.js';
import { Connection, PublicKey, Transaction } from '@solana/web3.js';
import { Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import {
TOKEN_PROGRAM_ID,
createTransferInstruction,
createAssociatedTokenAccountInstruction,
ASSOCIATED_TOKEN_PROGRAM_ID
ASSOCIATED_TOKEN_PROGRAM_ID,
getAssociatedTokenAddressSync
} 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_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 PAYMENT_RECEIVER_ADDRESS = process.env.NEXT_PUBLIC_SOLANA_TOKEN_RECIPIENT_ADDRESS;
export const connectSolanaWallet = async (walletType: SolanaWalletType): Promise<SolanaWalletState> => {
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);
}
};
// Helper function to find associated token address
async function findAssociatedTokenAddress(
walletAddress: PublicKey,
tokenMintAddress: PublicKey
@@ -83,37 +33,90 @@ async function findAssociatedTokenAddress(
)[0];
}
interface WalletAdapter {
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>;
}
export async function sendSolanaTokenPayment(
// Send native GOR payment
export async function sendNativeGorPayment(
wallet: WalletAdapter,
connection: Connection,
walletPublicKey: string,
tokenAmount: BN,
walletType: SolanaWalletType
tokenAmount: BN
): Promise<SolanaPaymentResult> {
try {
let wallet: WalletAdapter | null = null;
if (walletType === 'phantom') {
wallet = window.phantom?.solana || null;
} else if (walletType === 'solflare') {
wallet = window.solflare || null;
if (!wallet.connected || !wallet.publicKey) {
throw new Error('Wallet not connected');
}
if (!wallet) {
throw new Error(`${walletType} wallet not found`);
const senderPublicKey = new PublicKey(walletPublicKey);
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 mintPublicKey = new PublicKey(TOKEN_MINT);
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
console.log('Processing payment with keys:', {
console.log('Processing SPL token payment:', {
sender: senderPublicKey.toBase58(),
mint: mintPublicKey.toBase58(),
receiver: receiverPublicKey.toBase58(),
amount: tokenAmount.toString()
});
const senderATA = await findAssociatedTokenAddress(
@@ -182,9 +185,9 @@ export async function sendSolanaTokenPayment(
transaction.recentBlockhash = latestBlockhash.blockhash;
transaction.feePayer = senderPublicKey;
console.log('Sending transaction...');
const { signature } = await wallet.signAndSendTransaction(transaction);
console.log('Transaction sent:', signature);
console.log('Sending SPL token transaction...');
const signature = await wallet.sendTransaction(transaction, connection);
console.log('SPL token transaction sent:', signature);
// Confirm transaction
const confirmation = await connection.confirmTransaction({
@@ -202,6 +205,37 @@ export async function sendSolanaTokenPayment(
success: true,
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) {
console.error('Payment error:', error);
return {
@@ -209,18 +243,25 @@ export async function sendSolanaTokenPayment(
error: error instanceof Error ? error.message : 'Payment failed'
};
}
};
}
// Helper function to check wallet connection status
export const checkSolanaWalletConnection = (walletType: SolanaWalletType): boolean => {
try {
if (walletType === 'phantom') {
return window.phantom?.solana?.isConnected || false;
} else if (walletType === 'solflare') {
return window.solflare?.isConnected || false;
}
return false;
} catch {
return false;
// Helper function to get recipient address based on payment method
export function getRecipientAddress(paymentMethod: PaymentMethod): string {
switch (paymentMethod) {
case PaymentMethod.NAT_GOR:
// For native GOR payments, use the direct recipient address
return PAYMENT_RECEIVER_ADDRESS;
case PaymentMethod.SPL_TOKEN:
// For SPL token payments, use the associated token account
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS);
const mintPublicKey = new PublicKey(TOKEN_MINT);
const associatedTokenAddress = getAssociatedTokenAddressSync(
mintPublicKey,
receiverPublicKey,
true // Allow off-curve addresses
);
return associatedTokenAddress.toBase58();
default:
throw new Error(`Unsupported payment method: ${paymentMethod}`);
}
};
}
+9 -28
View File
@@ -1,25 +1,8 @@
import { Transaction } from "@solana/web3.js";
// extend the global Window interface to include Solana wallets
declare global {
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;
};
}
// Payment method types
export enum PaymentMethod {
NAT_GOR = 'nat-gor',
SPL_TOKEN = 'spl-token'
}
export interface RegistryConfig {
chainId: string;
rpcEndpoint: string;
@@ -45,7 +28,6 @@ export interface CreateRecordResponse {
message?: string;
}
export type SolanaWalletType = 'phantom' | 'solflare';
export interface SolanaPaymentResult {
success: boolean;
@@ -57,14 +39,13 @@ export interface PaymentModalProps {
isOpen: boolean;
onClose: () => void;
url: string;
onPaymentComplete: (txHash: string) => void;
walletState: SolanaWalletState;
onPaymentComplete: (txHash: string, paymentMethod: PaymentMethod) => void;
}
export interface SolanaWalletState {
connected: boolean;
publicKey: string | null;
walletType: SolanaWalletType | null;
export interface PaymentRequest {
paymentMethod: PaymentMethod;
amount: number; // in base units (lamports for native GOR, token base units for SPL)
}
export interface LaconicTransferResult {
+70 -43
View File
@@ -1,32 +1,76 @@
import BN from 'bn.js';
import { Connection, PublicKey } from '@solana/web3.js';
import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { Connection, ParsedInstruction, PartiallyDecodedInstruction } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
// Simplified transaction info extraction following reference implementation
const extractTxInfo = async (connection: Connection, transactionSignature: string): Promise<{ authority: string; amount: string }> => {
import { getRecipientAddress } from '@/services/solana';
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');
if (!result) {
throw new Error('Transaction not found');
}
const transferInstruction = result.transaction.message.instructions.find(
(instr) => 'parsed' in instr && instr.programId.equals(TOKEN_PROGRAM_ID)
);
let transferInstruction: ParsedInstruction | PartiallyDecodedInstruction | undefined;
if (!transferInstruction || !('parsed' in transferInstruction)) {
throw new Error('Transfer instruction not found');
switch (paymentMethod) {
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 (
connection: Connection,
transactionSignature: string,
tokenAmount: BN
expectedAmount: BN,
paymentMethod: PaymentMethod,
): Promise<{
valid: boolean,
reason?: string,
@@ -74,45 +118,28 @@ export const verifyUnusedSolanaPayment = async (
};
}
// Extract transaction info using simplified approach
const { amount, authority } = await extractTxInfo(connection, transactionSignature);
// Extract transaction info based on payment method
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);
if (transactionAmount.lt(tokenAmount)) {
if (transactionAmount.lt(expectedAmount)) {
return {
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(
new PublicKey(process.env.NEXT_PUBLIC_SOLANA_TOKEN_MINT_ADDRESS!),
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
const expectedRecipientAddress = getRecipientAddress(paymentMethod);
// Verify recipient address by checking the transaction instructions
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) {
if (destination !== expectedRecipientAddress) {
return {
valid: false,
reason: 'Valid Solana token transfer not found in transaction'
reason: `Invalid recipient address. Expected: ${expectedRecipientAddress}, Got: ${destination}`
};
}
@@ -128,4 +155,4 @@ export const verifyUnusedSolanaPayment = async (
reason: `Failed to verify transaction: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
};
};