From 7d4e392f9785c15025a91332739d8e16b0211b33 Mon Sep 17 00:00:00 2001 From: 0xrinegade <0xrinegade@gmail.com> Date: Thu, 26 Dec 2024 08:53:48 +0300 Subject: [PATCH] fix build error --- .vscode/settings.json | 5 + app/account/[address]/page.tsx | 12 +- app/address/[address]/opengraph-image.tsx | 2 +- app/api/block/route.ts | 44 ++ app/api/blocks/[slot]/route.ts | 42 ++ app/block/[slot]/metadata.ts | 12 + app/block/[slot]/opengraph-image.tsx | 170 +++++++ app/block/[slot]/page.tsx | 23 + app/block/layout.tsx | 18 + app/page.tsx | 81 +-- app/token/[mint]/opengraph-image.tsx | 167 +++++++ app/token/[mint]/page.tsx | 23 + app/tx/[signature]/page.tsx | 2 +- components/BlockDetails.tsx | 182 +++++++ components/RecentBlocks.tsx | 63 +++ components/RecentTransactions.tsx | 2 +- components/TokenDetails.tsx | 188 +++++++ components/ui/card.tsx | 79 +++ lib/solana.ts | 575 +++++++++++++--------- lib/utils.ts | 19 + package.json | 3 + pnpm-lock.yaml | 70 +++ tsconfig.json | 2 +- 23 files changed, 1472 insertions(+), 312 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/api/block/route.ts create mode 100644 app/api/blocks/[slot]/route.ts create mode 100644 app/block/[slot]/metadata.ts create mode 100644 app/block/[slot]/opengraph-image.tsx create mode 100644 app/block/[slot]/page.tsx create mode 100644 app/block/layout.tsx create mode 100644 app/token/[mint]/opengraph-image.tsx create mode 100644 app/token/[mint]/page.tsx create mode 100644 components/BlockDetails.tsx create mode 100644 components/RecentBlocks.tsx create mode 100644 components/TokenDetails.tsx create mode 100644 components/ui/card.tsx create mode 100644 lib/utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e220dc7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.lightbulb.enabled": "onCode", + "editor.experimental.treeSitterTelemetry": false, + "editor.experimentalInlineEdit.enabled": true +} \ No newline at end of file diff --git a/app/account/[address]/page.tsx b/app/account/[address]/page.tsx index 95413f8..0f15ea6 100644 --- a/app/account/[address]/page.tsx +++ b/app/account/[address]/page.tsx @@ -8,9 +8,11 @@ import type { TransactionInfo } from '@/lib/solana'; interface AccountInfo { address: string; - balance: number; - executable: boolean; + lamports: number; owner: string; + executable: boolean; + rentEpoch: number; + data: Buffer; } export default function AccountPage() { @@ -88,7 +90,7 @@ export default function AccountPage() {
Balance - {accountInfo.balance.toFixed(9)} SOL + {(accountInfo.lamports / 1e9).toFixed(9)} SOL
@@ -117,8 +119,8 @@ export default function AccountPage() { {tx.signature}
- Amount - {tx.amount.toFixed(9)} SOL + Fee + {tx.fee.toFixed(9)} SOL
diff --git a/app/address/[address]/opengraph-image.tsx b/app/address/[address]/opengraph-image.tsx index 6de7750..d7ab15c 100644 --- a/app/address/[address]/opengraph-image.tsx +++ b/app/address/[address]/opengraph-image.tsx @@ -15,7 +15,7 @@ export default async function Image({ params }: { params: { address: string } }) const title = 'Account Overview'; const description = account - ? `Balance: ${account.balance.toFixed(4)} SOL` + ? `Balance: ${(account.lamports / 1e9).toFixed(4)} SOL` : 'Solana Account Explorer'; return new ImageResponse( diff --git a/app/api/block/route.ts b/app/api/block/route.ts new file mode 100644 index 0000000..8ec9bcd --- /dev/null +++ b/app/api/block/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { connection } from '@/lib/solana'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const slot = searchParams.get('slot'); + + if (!slot) { + return NextResponse.json( + { error: 'Slot parameter is required' }, + { status: 400 } + ); + } + + try { + const slotNumber = parseInt(slot); + if (isNaN(slotNumber)) { + return NextResponse.json( + { error: 'Invalid slot number' }, + { status: 400 } + ); + } + + const [block, blockTime] = await Promise.all([ + connection.getBlock(slotNumber, { maxSupportedTransactionVersion: 0 }), + connection.getBlockTime(slotNumber), + ]); + + if (!block) { + return NextResponse.json( + { error: 'Block not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ block, blockTime }); + } catch (error) { + console.error('Error fetching block:', error); + return NextResponse.json( + { error: 'Failed to fetch block data' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/blocks/[slot]/route.ts b/app/api/blocks/[slot]/route.ts new file mode 100644 index 0000000..fbc8202 --- /dev/null +++ b/app/api/blocks/[slot]/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { connection } from '@/lib/solana'; + +export async function GET(request: Request) { + try { + const slot = request.url.split('/').pop(); + if (!slot) { + return NextResponse.json( + { error: 'Slot parameter is required' }, + { status: 400 } + ); + } + + const slotNumber = parseInt(slot); + if (isNaN(slotNumber)) { + return NextResponse.json( + { error: 'Invalid slot number' }, + { status: 400 } + ); + } + + const [block, blockTime] = await Promise.all([ + connection.getBlock(slotNumber, { maxSupportedTransactionVersion: 0 }), + connection.getBlockTime(slotNumber), + ]); + + if (!block) { + return NextResponse.json( + { error: 'Block not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ block, blockTime }); + } catch (error) { + console.error('Error fetching block data:', error); + return NextResponse.json( + { error: 'Failed to fetch block data' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/block/[slot]/metadata.ts b/app/block/[slot]/metadata.ts new file mode 100644 index 0000000..b973486 --- /dev/null +++ b/app/block/[slot]/metadata.ts @@ -0,0 +1,12 @@ +import { Metadata } from 'next'; + +export async function generateMetadata({ + params, +}: { + params: { slot: string }; +}): Promise { + return { + title: `Block #${params.slot} | OPENSVM`, + description: `View details of Solana block #${params.slot} on OPENSVM`, + }; +} \ No newline at end of file diff --git a/app/block/[slot]/opengraph-image.tsx b/app/block/[slot]/opengraph-image.tsx new file mode 100644 index 0000000..3fb5a13 --- /dev/null +++ b/app/block/[slot]/opengraph-image.tsx @@ -0,0 +1,170 @@ +import { ImageResponse } from 'next/og'; +import { connection } from '@/lib/solana'; +import { formatNumber } from '@/lib/utils'; + +export const runtime = 'edge'; +export const alt = 'Block Details'; +export const size = { + width: 1200, + height: 630, +}; +export const contentType = 'image/png'; + +export default async function Image({ params }: { params: { slot: string } }) { + try { + const slotNumber = parseInt(params.slot); + const [block, blockTime] = await Promise.all([ + connection.getBlock(slotNumber, { maxSupportedTransactionVersion: 0 }), + connection.getBlockTime(slotNumber), + ]); + + if (!block) { + throw new Error('Block not found'); + } + + const totalRewards = block.rewards.reduce((acc, r) => acc + r.lamports, 0) / 1e9; + + return new ImageResponse( + ( +
+ {/* Logo */} +
+
+
+ S +
+
+
+ OPENSVM +
+
+ + {/* Content */} +
+
+ Block #{formatNumber(slotNumber)} +
+
+ {blockTime ? new Date(blockTime * 1000).toLocaleString() : 'Unknown time'} +
+
+ {formatNumber(block.transactions.length)} Transactions • {formatNumber(totalRewards)} SOL in Rewards +
+
+ Parent Slot: {formatNumber(block.parentSlot)} +
+
+ + {/* Footer */} +
+
+ opensvm.com +
+
+
+ ), + { + width: 1200, + height: 630, + }, + ); + } catch (e: any) { + console.log(`${e.message}`); + return new Response(`Failed to generate the image`, { + status: 500, + }); + } +} \ No newline at end of file diff --git a/app/block/[slot]/page.tsx b/app/block/[slot]/page.tsx new file mode 100644 index 0000000..2227007 --- /dev/null +++ b/app/block/[slot]/page.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next'; +import BlockDetails from '@/components/BlockDetails'; + +interface PageProps { + params: Promise<{ slot: string }>; +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const resolvedParams = await params; + return { + title: `Block #${resolvedParams.slot} | OPENSVM`, + description: `View details of Solana block #${resolvedParams.slot} on OPENSVM`, + }; +} + +export default async function BlockPage({ + params, +}: PageProps) { + const resolvedParams = await params; + return ; +} \ No newline at end of file diff --git a/app/block/layout.tsx b/app/block/layout.tsx new file mode 100644 index 0000000..95e9ca2 --- /dev/null +++ b/app/block/layout.tsx @@ -0,0 +1,18 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Block Details | OPENSVM', + description: 'View Solana block details on OPENSVM', +}; + +export default function BlockLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index aa782f2..95b4b3c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,83 +3,12 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Text, Button } from 'rinlab'; -import RecentTransactions from '@/components/RecentTransactions'; - -export default function HomePage() { - const [searchQuery, setSearchQuery] = useState(''); - const router = useRouter(); - - const handleSearch = () => { - if (searchQuery) { - router.push(`/account/${searchQuery}`); - } - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - } - }; +import RecentBlocks from '@/components/RecentBlocks'; +export default function Home() { return ( -
-
-
-
-
- OPENSVM -
- $198.35 - +3.15% - Avg Fee: 0.00001304 -
- -
- -
-
-
- -
-
- - Solana Block Explorer - - - Search for any Solana address, transaction, token, or NFT - -
-
- setSearchQuery(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="Search transactions, blocks, programs and tokens" - className="flex-1 px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#00ffbd] focus:border-transparent" - /> - -
-
-
- -
- -
-
-
+
+ +
); } diff --git a/app/token/[mint]/opengraph-image.tsx b/app/token/[mint]/opengraph-image.tsx new file mode 100644 index 0000000..ebe5d38 --- /dev/null +++ b/app/token/[mint]/opengraph-image.tsx @@ -0,0 +1,167 @@ +import { ImageResponse } from '@vercel/og'; +import { getTokenInfo } from '@/lib/solana'; +import { formatNumber } from '@/lib/utils'; + +export const runtime = 'edge'; +export const alt = 'Token Details'; +export const size = { + width: 1200, + height: 630, +}; +export const contentType = 'image/png'; + +export default async function Image({ params }: { params: { mint: string } }) { + try { + const token = await getTokenInfo(params.mint); + + const title = token?.metadata?.name || 'Token Overview'; + const description = token?.metadata?.description || 'Solana Token Explorer'; + + return new ImageResponse( + ( +
+ {/* Logo */} +
+
+
+ S +
+
+
+ OPENSVM +
+
+ + {/* Content */} +
+
+ {title} +
+ {token && ( +
+ Supply: {formatNumber(token.supply)} • Holders: {formatNumber(token.holders)} +
+ )} +
+ {description} +
+ {token && ( +
+ {params.mint.slice(0, 20)}...{params.mint.slice(-20)} +
+ )} +
+ + {/* Footer */} +
+
+ opensvm.com +
+
+
+ ), + { + width: 1200, + height: 630, + }, + ); + } catch (e: any) { + console.log(`${e.message}`); + return new Response(`Failed to generate the image`, { + status: 500, + }); + } +} \ No newline at end of file diff --git a/app/token/[mint]/page.tsx b/app/token/[mint]/page.tsx new file mode 100644 index 0000000..2689944 --- /dev/null +++ b/app/token/[mint]/page.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next'; +import TokenDetails from '@/components/TokenDetails'; + +interface PageProps { + params: Promise<{ mint: string }>; +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const resolvedParams = await params; + return { + title: `Token ${resolvedParams.mint} | OPENSVM`, + description: `View details of Solana token ${resolvedParams.mint} on OPENSVM`, + }; +} + +export default async function TokenPage({ + params, +}: PageProps) { + const resolvedParams = await params; + return ; +} \ No newline at end of file diff --git a/app/tx/[signature]/page.tsx b/app/tx/[signature]/page.tsx index 3a8769a..70e8a98 100644 --- a/app/tx/[signature]/page.tsx +++ b/app/tx/[signature]/page.tsx @@ -174,7 +174,7 @@ export default function TransactionPage() {
Status
-
+
{transaction.status}
diff --git a/components/BlockDetails.tsx b/components/BlockDetails.tsx new file mode 100644 index 0000000..27fa3c3 --- /dev/null +++ b/components/BlockDetails.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatNumber } from '@/lib/utils'; + +interface BlockData { + block: any; + blockTime: number | null; +} + +interface Props { + slot: string; +} + +export default function BlockDetails({ slot }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + const response = await fetch(`/api/blocks/${slot}`); + const json = await response.json(); + + if (!response.ok) { + throw new Error(json.error || 'Failed to fetch block data'); + } + + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch block data'); + } finally { + setLoading(false); + } + } + + fetchData(); + }, [slot]); + + if (loading) { + return
Loading...
; + } + + if (error || !data) { + return
Error: {error || 'Failed to load block data'}
; + } + + const { block, blockTime } = data; + + return ( +
+ {/* Block Overview */} + + + Block #{formatNumber(parseInt(slot))} + + +
+
+
Block Time
+
+ {blockTime ? new Date(blockTime * 1000).toLocaleString() : 'Unknown'} +
+
+
+
Block Height
+
+ {block.blockHeight ? formatNumber(block.blockHeight) : 'N/A'} +
+
+
+
Parent Slot
+
+ {formatNumber(block.parentSlot)} +
+
+
+
Transactions
+
+ {formatNumber(block.transactions.length)} +
+
+
+
Total Rewards
+
+ {formatNumber(block.rewards.reduce((acc, r) => acc + r.lamports, 0) / 1e9)} SOL +
+
+
+
Previous Blockhash
+
+ {block.previousBlockhash} +
+
+
+
+
+ + {/* Block Rewards */} + + + Block Rewards + + +
+ {block.rewards.map((reward: any, index: number) => ( +
+
+
+ {reward.pubkey} +
+
+ {reward.rewardType} +
+
+
+
+
Reward
+
+ {formatNumber(reward.lamports / 1e9)} SOL +
+
+
+
Post Balance
+
+ {formatNumber(reward.postBalance / 1e9)} SOL +
+
+
+
+ ))} +
+
+
+ + {/* Block Transactions */} + + + Transactions + + +
+ {block.transactions.map((tx: any, index: number) => ( +
+
+
+ {tx.transaction.signatures[0]} +
+
+ Status: {tx.meta?.err ? 'Failed' : 'Success'} +
+
+
+
+
Fee
+
+ {formatNumber((tx.meta?.fee || 0) / 1e9)} SOL +
+
+
+
Instructions
+
+ {tx.transaction.message.instructions.length} +
+
+
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/RecentBlocks.tsx b/components/RecentBlocks.tsx new file mode 100644 index 0000000..025f0eb --- /dev/null +++ b/components/RecentBlocks.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { connection } from '@/lib/solana'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatNumber } from '@/lib/utils'; +import Link from 'next/link'; + +export default function RecentBlocks() { + const [blocks, setBlocks] = useState([]); + + useEffect(() => { + let mounted = true; + + async function subscribeToBlocks() { + const slot = await connection.getSlot(); + if (mounted) { + setBlocks([slot]); + } + + const id = connection.onSlotChange(({ slot }) => { + if (mounted) { + setBlocks(prev => [slot, ...prev].slice(0, 10)); + } + }); + + return () => { + connection.removeSlotChangeListener(id); + }; + } + + const unsubscribePromise = subscribeToBlocks(); + + return () => { + mounted = false; + unsubscribePromise.then(unsubscribe => unsubscribe()); + }; + }, []); + + return ( + + + Recent Blocks + + +
+ {blocks.map(slot => ( + +
+
#{formatNumber(slot)}
+
View Details →
+
+ + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/RecentTransactions.tsx b/components/RecentTransactions.tsx index 4471c33..bd1c6b6 100644 --- a/components/RecentTransactions.tsx +++ b/components/RecentTransactions.tsx @@ -92,7 +92,7 @@ export default function RecentTransactions() {
-
+
{tx.type} diff --git a/components/TokenDetails.tsx b/components/TokenDetails.tsx new file mode 100644 index 0000000..b7b92f1 --- /dev/null +++ b/components/TokenDetails.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { getTokenInfo } from '@/lib/solana'; +import { formatNumber } from '@/lib/utils'; +import { Stack } from 'rinlab'; +import Image from 'next/image'; +import { useEffect, useState } from 'react'; + +interface TokenData { + metadata?: { + name?: string; + symbol?: string; + description?: string; + image?: string; + updateAuthority?: string; + attributes?: Array<{ + trait_type: string; + value: string; + }>; + }; + price?: number; + marketCap?: number; + supply?: number; + holders?: number; + decimals: number; + volume24h?: number; +} + +interface Props { + mint: string; +} + +export default function TokenDetails({ mint }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + const tokenInfo = await getTokenInfo(mint); + setData(tokenInfo); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch token data'); + } finally { + setLoading(false); + } + } + + fetchData(); + }, [mint]); + + if (loading) { + return
Loading...
; + } + + if (error || !data) { + return
Error: {error || 'Failed to load token data'}
; + } + + return ( +
+ {/* Token Overview */} + + + + {data.metadata?.image && ( + {data.metadata.name + )} +
+

+ {data.metadata?.name || 'Unknown Token'} +

+

+ {data.metadata?.symbol || mint} +

+
+
+
+ +
+ +
Price
+
+ ${data.price?.toFixed(4) || 'N/A'} +
+
+ +
Market Cap
+
+ ${formatNumber(data.marketCap || 0)} +
+
+ +
Supply
+
+ {formatNumber(data.supply || 0)} +
+
+ +
Holders
+
+ {formatNumber(data.holders || 0)} +
+
+ +
Decimals
+
+ {data.decimals} +
+
+ +
Volume (24h)
+
+ ${formatNumber(data.volume24h || 0)} +
+
+
+
+
+ + {/* Token Info */} + + + Token Information + + +
+
+
+
Mint Address
+
+ {mint} +
+
+ {data.metadata?.updateAuthority && ( +
+
Update Authority
+
+ {data.metadata.updateAuthority} +
+
+ )} +
+ {data.metadata?.description && ( +
+
Description
+
+ {data.metadata.description} +
+
+ )} +
+
+
+ + {/* Token Attributes */} + {data.metadata?.attributes && data.metadata.attributes.length > 0 && ( + + + Attributes + + +
+ {data.metadata.attributes.map((attr, index) => ( +
+
+ {attr.trait_type} +
+
+ {attr.value} +
+
+ ))} +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..e855d73 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/lib/solana.ts b/lib/solana.ts index c73c4ac..34471da 100644 --- a/lib/solana.ts +++ b/lib/solana.ts @@ -1,37 +1,225 @@ -import { Connection, PublicKey } from '@solana/web3.js'; +import { Connection, PublicKey, SystemProgram, VersionedMessage, MessageV0, Message, MessageCompiledInstruction, CompiledInstruction } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID, getAccount, getMint } from '@solana/spl-token'; -const SOLANA_RPC_URL = 'https://solana-mainnet.core.chainstack.com/263c9f53f4e4cdb897c0edc4a64cd007'; -const SOLANA_WS_URL = 'wss://solana-mainnet.core.chainstack.com/263c9f53f4e4cdb897c0edc4a64cd007'; +export const connection = new Connection(process.env.NEXT_PUBLIC_RPC_URL || 'https://api.mainnet-beta.solana.com'); -const connection = new Connection(SOLANA_RPC_URL, { - wsEndpoint: SOLANA_WS_URL, - commitment: 'confirmed', -}); +const METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); -export { connection }; +function getInstructions(message: VersionedMessage | Message) { + if ('compiledInstructions' in message) { + return (message as MessageV0).compiledInstructions; + } + return (message as Message).instructions; +} + +function getProgramId(instruction: MessageCompiledInstruction | CompiledInstruction): number { + if ('programIdIndex' in instruction && typeof instruction.programIdIndex === 'number') { + return instruction.programIdIndex; + } + throw new Error('Invalid instruction type'); +} + +function getAccounts(instruction: MessageCompiledInstruction | CompiledInstruction) { + if ('accountKeyIndexes' in instruction) { + return instruction.accountKeyIndexes; + } + return instruction.accounts; +} + +export async function getTokenInfo(mintAddress: string) { + try { + const mintPublicKey = new PublicKey(mintAddress); + const [mint, tokenAccounts] = await Promise.all([ + getMint(connection, mintPublicKey), + connection.getProgramAccounts(TOKEN_PROGRAM_ID, { + filters: [ + { + memcmp: { + offset: 0, + bytes: mintPublicKey.toBase58(), + }, + }, + ], + }), + ]); + + // Get metadata + const [metadataPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from('metadata'), + METADATA_PROGRAM_ID.toBuffer(), + mintPublicKey.toBuffer(), + ], + METADATA_PROGRAM_ID + ); + + let metadata: any = null; + try { + const metadataAccount = await connection.getAccountInfo(metadataPda); + if (metadataAccount) { + // Parse metadata manually since we don't have access to the full Metaplex SDK + const name = metadataAccount.data.slice(1, 33).toString().replace(/\0/g, ''); + const symbol = metadataAccount.data.slice(33, 65).toString().replace(/\0/g, ''); + const uri = metadataAccount.data.slice(65, 200).toString().replace(/\0/g, ''); + metadata = { name, symbol, uri }; + } + } catch (e) { + console.error('Failed to fetch metadata:', e); + } + + // Calculate total supply and holders + let totalSupply = 0n; + const holders = new Set(); + + for (const { account, pubkey } of tokenAccounts) { + const tokenAccount = await getAccount(connection, pubkey); + totalSupply += tokenAccount.amount; + if (tokenAccount.amount > 0n) { + holders.add(tokenAccount.owner.toBase58()); + } + } -export type TransactionInfo = { + return { + decimals: mint.decimals, + supply: Number(totalSupply), + holders: holders.size, + metadata: metadata ? { + name: metadata.name, + symbol: metadata.symbol, + uri: metadata.uri, + description: '', + sellerFeeBasisPoints: 0, + creators: null, + collection: null, + uses: null, + } : null, + }; + } catch (error) { + console.error('Error fetching token info:', error); + return null; + } +} + +export interface TransactionInfo { signature: string; - timestamp: Date | null; - status: 'Success' | 'Failed'; - fee: number; + slot: number; + timestamp: number; + status: 'success' | 'error'; type: string; + fee: number; + signer: string; from: string; to: string; amount: number; -}; +} + +export async function getAccountInfo(address: string) { + try { + const publicKey = new PublicKey(address); + const accountInfo = await connection.getAccountInfo(publicKey); + + if (!accountInfo) { + return null; + } -export type DetailedTransactionInfo = TransactionInfo & { + return { + address, + lamports: accountInfo.lamports, + owner: accountInfo.owner.toBase58(), + executable: accountInfo.executable, + rentEpoch: accountInfo.rentEpoch, + data: accountInfo.data, + }; + } catch (error) { + console.error('Error fetching account info:', error); + return null; + } +} + +export async function getTransactionHistory(address: string, limit = 10) { + try { + const publicKey = new PublicKey(address); + const signatures = await connection.getSignaturesForAddress(publicKey, { limit }); + + const transactions: TransactionInfo[] = await Promise.all( + signatures.map(async (sig) => { + const tx = await connection.getTransaction(sig.signature, { + maxSupportedTransactionVersion: 0, + }); + + if (!tx) return null; + + // Extract from and to addresses from the first transfer instruction + const accountKeys = tx.transaction.message.getAccountKeys(); + let from = accountKeys.get(0)!.toBase58(); + let to = from; + let amount = 0; + let type = 'Unknown'; + + // Try to determine transaction type and extract transfer details + const instructions = getInstructions(tx.transaction.message); + + if (tx.meta && instructions.length > 0) { + const instruction = instructions[0]; + const program = accountKeys.get(getProgramId(instruction)); + + if (program?.equals(SystemProgram.programId)) { + type = 'System Transfer'; + if (instruction.data.length >= 8) { + // Convert data to Buffer if it's a string + const dataBuffer = Buffer.from(instruction.data); + // Assuming it's a transfer instruction + amount = Number(dataBuffer.readBigInt64LE(8)) / 1e9; + const accounts = getAccounts(instruction); + from = accountKeys.get(accounts[0])!.toBase58(); + to = accountKeys.get(accounts[1])!.toBase58(); + } + } else if (program?.equals(TOKEN_PROGRAM_ID)) { + type = 'Token Transfer'; + // Add token transfer parsing logic here if needed + } + } + + return { + signature: sig.signature, + slot: sig.slot, + timestamp: sig.blockTime || 0, + status: sig.err ? 'error' : 'success', + type, + fee: (tx.meta?.fee || 0) / 1e9, + signer: accountKeys.get(0)!.toBase58(), + from, + to, + amount, + }; + }) + ); + + return transactions.filter((tx): tx is TransactionInfo => tx !== null); + } catch (error) { + console.error('Error fetching transaction history:', error); + return []; + } +} + +export interface DetailedTransactionInfo { + signature: string; slot: number; - blockTime: Date | null; - recentBlockhash: string; + blockTime: number | null; + status: 'success' | 'error'; + fee: number; + from: string; + to: string; + amount: number; + type: string; + computeUnits: number; + accounts: string[]; instructions: { programId: string; data: string; }[]; logs: string[]; - computeUnits: number; -}; +} export async function getTransactionDetails(signature: string): Promise { try { @@ -39,78 +227,58 @@ export async function getTransactionDetails(signature: string): Promise Math.abs(amount)) { - amount = balanceChange; - from = accountKeys[i]?.toBase58() || 'Unknown'; - } - } + let type = 'Unknown'; - // Find the largest balance increase (receiver) - for (let i = 0; i < postBalances.length; i++) { - const balanceChange = (postBalances[i] || 0) - (preBalances[i] || 0); - if (balanceChange > 0 && balanceChange > Math.abs(amount)) { - to = accountKeys[i]?.toBase58() || 'Unknown'; - } - } + // Try to determine transaction type and extract transfer details + const instructions = getInstructions(tx.transaction.message); - // If no receiver found, use the first non-sender account - if (to === 'Unknown' && accountKeys.length > 1) { - to = accountKeys.find(key => key?.toBase58() !== from)?.toBase58() || 'Unknown'; + if (tx.meta && instructions.length > 0) { + const instruction = instructions[0]; + const program = accountKeys.get(getProgramId(instruction)); + + if (program?.equals(SystemProgram.programId)) { + type = 'System Transfer'; + if (instruction.data.length >= 8) { + // Convert data to Buffer if it's a string + const dataBuffer = Buffer.from(instruction.data); + // Assuming it's a transfer instruction + amount = Number(dataBuffer.readBigInt64LE(8)) / 1e9; + const accounts = getAccounts(instruction); + from = accountKeys.get(accounts[0])!.toBase58(); + to = accountKeys.get(accounts[1])!.toBase58(); + } + } else if (program?.equals(TOKEN_PROGRAM_ID)) { + type = 'Token Transfer'; + // Add token transfer parsing logic here if needed + } } - // Convert lamports to SOL - amount = Math.abs(amount) / 1e9; - return { signature, - timestamp: tx.blockTime ? new Date(tx.blockTime * 1000) : null, - status: tx.meta?.err ? 'Failed' : 'Success', + slot: tx.slot, + blockTime: tx.blockTime, + status: tx.meta?.err ? 'error' : 'success', fee: (tx.meta?.fee || 0) / 1e9, - type: determineTransactionType(tx), from, to, amount, - slot: tx.slot || 0, - blockTime: tx.blockTime ? new Date(tx.blockTime * 1000) : null, - recentBlockhash: tx.transaction.message.recentBlockhash || '', + type, + computeUnits: tx.meta?.computeUnitsConsumed || 0, + accounts: accountKeys.staticAccountKeys.map(key => key.toBase58()), instructions: instructions.map(ix => ({ - programId: accountKeys[ix.programIdIndex]?.toBase58() || 'Unknown', - data: ix.data?.toString() || '', + programId: accountKeys.get(getProgramId(ix))!.toBase58(), + data: ix.data.toString('base64'), })), logs: tx.meta?.logMessages || [], - computeUnits: tx.meta?.computeUnitsConsumed || 0, }; } catch (error) { console.error('Error fetching transaction details:', error); @@ -118,124 +286,61 @@ export async function getTransactionDetails(signature: string): Promise { try { - const message = tx.transaction.message; - if (!message) return 'Unknown'; - - // Handle instructions for both legacy and versioned messages - const instructions = 'instructions' in message - ? message.instructions - : message.compiledInstructions; - - if (!instructions?.[0]) return 'Unknown'; - - const accountKeys = 'accountKeys' in message - ? message.accountKeys - : message.staticAccountKeys; - - if (!accountKeys || accountKeys.length === 0) { - return 'Unknown'; - } - - const instruction = instructions[0]; - const programId = accountKeys[instruction.programIdIndex]?.toBase58(); + const signatures = await connection.getSignaturesForAddress(SystemProgram.programId, { limit }); - if (!programId) { - return 'Unknown'; - } + const transactions: TransactionInfo[] = await Promise.all( + signatures.map(async (sig) => { + const tx = await connection.getTransaction(sig.signature, { + maxSupportedTransactionVersion: 0, + }); + + if (!tx) return null; - // Common program IDs - const SYSTEM_PROGRAM = '11111111111111111111111111111111'; - const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; - const ASSOCIATED_TOKEN = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; - const METADATA_PROGRAM = 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'; - const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; - - switch (programId) { - case SYSTEM_PROGRAM: - return 'System Transfer'; - case TOKEN_PROGRAM: - return 'Token Transfer'; - case ASSOCIATED_TOKEN: - return 'Token Account'; - case METADATA_PROGRAM: - return 'NFT'; - case MEMO_PROGRAM: - return 'Memo'; - default: - return 'Program Call'; - } - } catch (error) { - return 'Unknown'; - } -} + // Extract from and to addresses from the first transfer instruction + const accountKeys = tx.transaction.message.getAccountKeys(); + let from = accountKeys.get(0)!.toBase58(); + let to = from; + let amount = 0; + let type = 'Unknown'; -export async function subscribeToTransactions(callback: (transaction: TransactionInfo) => void) { - try { - const subscriptionId = connection.onLogs( - 'all', - async (logs) => { - try { - // Get full transaction details - const tx = await getTransactionDetails(logs.signature); + // Try to determine transaction type and extract transfer details + const instructions = getInstructions(tx.transaction.message); + + if (tx.meta && instructions.length > 0) { + const instruction = instructions[0]; + const program = accountKeys.get(getProgramId(instruction)); - if (tx) { - callback(tx); - } else { - // Fallback to basic info if detailed fetch fails - callback({ - signature: logs.signature, - timestamp: new Date(), - status: logs.err ? 'Failed' : 'Success', - fee: 0.000005, - type: 'Unknown', - from: 'Unknown', - to: 'Unknown', - amount: 0, - }); + if (program?.equals(SystemProgram.programId)) { + type = 'System Transfer'; + if (instruction.data.length >= 8) { + // Convert data to Buffer if it's a string + const dataBuffer = Buffer.from(instruction.data); + // Assuming it's a transfer instruction + amount = Number(dataBuffer.readBigInt64LE(8)) / 1e9; + const accounts = getAccounts(instruction); + from = accountKeys.get(accounts[0])!.toBase58(); + to = accountKeys.get(accounts[1])!.toBase58(); + } + } else if (program?.equals(TOKEN_PROGRAM_ID)) { + type = 'Token Transfer'; + // Add token transfer parsing logic here if needed } - } catch (error) { - console.error('Error processing transaction logs:', error); } - }, - 'confirmed' - ); - - return () => { - connection.removeOnLogsListener(subscriptionId); - }; - } catch (error) { - console.error('Error setting up transaction subscription:', error); - return () => {}; - } -} -export async function getInitialTransactions(): Promise { - try { - const signatures = await connection.getSignaturesForAddress( - new PublicKey('11111111111111111111111111111111'), - { limit: 10 } - ); - - const transactions = await Promise.all( - signatures.map(async (sig) => { - try { - const tx = await getTransactionDetails(sig.signature); - return tx || { - signature: sig.signature, - timestamp: sig.blockTime ? new Date(sig.blockTime * 1000) : new Date(), - status: sig.err ? 'Failed' : 'Success', - fee: 0.000005, - type: 'Unknown', - from: 'Unknown', - to: 'Unknown', - amount: 0 - }; - } catch (error) { - console.error('Error fetching transaction details:', error); - return null; - } + return { + signature: sig.signature, + slot: sig.slot, + timestamp: sig.blockTime || 0, + status: sig.err ? 'error' : 'success', + type, + fee: (tx.meta?.fee || 0) / 1e9, + signer: accountKeys.get(0)!.toBase58(), + from, + to, + amount, + }; }) ); @@ -246,53 +351,69 @@ export async function getInitialTransactions(): Promise { } } -export async function getAccountInfo(address: string) { - try { - const pubkey = new PublicKey(address); - const account = await connection.getAccountInfo(pubkey); - const balance = await connection.getBalance(pubkey); +export function subscribeToTransactions(callback: (tx: TransactionInfo) => void): () => void { + const id = connection.onLogs(SystemProgram.programId, async (logs) => { + if (!logs.err) { + try { + const signature = logs.signature; + const tx = await connection.getTransaction(signature, { + maxSupportedTransactionVersion: 0, + }); + + if (tx) { + // Extract from and to addresses from the first transfer instruction + const accountKeys = tx.transaction.message.getAccountKeys(); + let from = accountKeys.get(0)!.toBase58(); + let to = from; + let amount = 0; + let type = 'Unknown'; - return { - address, - balance: balance / 1e9, // Convert lamports to SOL - executable: account?.executable || false, - owner: account?.owner?.toBase58() || 'Unknown', - }; - } catch (error) { - console.error('Error fetching account info:', error); - return null; - } -} + // Try to determine transaction type and extract transfer details + const instructions = getInstructions(tx.transaction.message); -export async function getTransactionHistory(address: string, limit = 10): Promise { - try { - const pubkey = new PublicKey(address); - const signatures = await connection.getSignaturesForAddress(pubkey, { limit }); - - const transactions = await Promise.all( - signatures.map(async (sig) => { - try { - const tx = await getTransactionDetails(sig.signature); - return tx || { - signature: sig.signature, - timestamp: sig.blockTime ? new Date(sig.blockTime * 1000) : null, - status: sig.err ? 'Failed' : 'Success', - fee: 0.000005, - type: 'Unknown', - from: address, - to: 'Unknown', - amount: 0 + if (tx.meta && instructions.length > 0) { + const instruction = instructions[0]; + const program = accountKeys.get(getProgramId(instruction)); + + if (program?.equals(SystemProgram.programId)) { + type = 'System Transfer'; + if (instruction.data.length >= 8) { + // Convert data to Buffer if it's a string + const dataBuffer = Buffer.from(instruction.data); + // Assuming it's a transfer instruction + amount = Number(dataBuffer.readBigInt64LE(8)) / 1e9; + const accounts = getAccounts(instruction); + from = accountKeys.get(accounts[0])!.toBase58(); + to = accountKeys.get(accounts[1])!.toBase58(); + } + } else if (program?.equals(TOKEN_PROGRAM_ID)) { + type = 'Token Transfer'; + // Add token transfer parsing logic here if needed + } + } + + const transactionInfo: TransactionInfo = { + signature, + slot: tx.slot, + timestamp: tx.blockTime || 0, + status: 'success', + type, + fee: (tx.meta?.fee || 0) / 1e9, + signer: accountKeys.get(0)!.toBase58(), + from, + to, + amount, }; - } catch (error) { - console.error('Error fetching transaction details:', error); - return null; + + callback(transactionInfo); } - }) - ); + } catch (error) { + console.error('Error processing transaction:', error); + } + } + }); - return transactions.filter((tx): tx is TransactionInfo => tx !== null); - } catch (error) { - console.error('Error fetching transaction history:', error); - return []; - } + return () => { + connection.removeOnLogsListener(id); + }; } \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..05daa53 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,19 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function formatNumber(num: number): string { + if (num >= 1e9) { + return (num / 1e9).toFixed(2) + 'B'; + } + if (num >= 1e6) { + return (num / 1e6).toFixed(2) + 'M'; + } + if (num >= 1e3) { + return (num / 1e3).toFixed(2) + 'K'; + } + return num.toLocaleString(); +} \ No newline at end of file diff --git a/package.json b/package.json index b3f9ede..c9739b3 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "lint": "next lint" }, "dependencies": { + "@metaplex-foundation/mpl-token-metadata": "^3.3.0", "@mozilla/readability": "^0.5.0", "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.98.0", "@swc/helpers": "^0.5.15", "@vercel/og": "^0.6.4", + "clsx": "^2.1.1", "date-fns": "^4.1.0", "encoding": "^0.1.13", "eventsource-parser": "^1.1.2", @@ -24,6 +26,7 @@ "react-dom": "18.2.0", "react-hot-toast": "^2.4.1", "rinlab": "^0.2.0", + "tailwind-merge": "^2.6.0", "together-ai": "^0.6.0-alpha.3", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e960743..301ebd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,6 +1,9 @@ lockfileVersion: '6.0' dependencies: + '@metaplex-foundation/mpl-token-metadata': + specifier: ^3.3.0 + version: 3.3.0(@metaplex-foundation/umi@0.9.2) '@mozilla/readability': specifier: ^0.5.0 version: 0.5.0 @@ -16,6 +19,9 @@ dependencies: '@vercel/og': specifier: ^0.6.4 version: 0.6.4 + clsx: + specifier: ^2.1.1 + version: 2.1.1 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -46,6 +52,9 @@ dependencies: rinlab: specifier: ^0.2.0 version: 0.2.0(@radix-ui/react-toast@1.2.4)(class-variance-authority@0.7.1)(clsx@2.1.1)(framer-motion@11.15.0)(lucide-react@0.294.0)(react-dom@18.2.0)(react@18.2.0)(tailwind-merge@2.6.0)(tailwindcss@3.4.1) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 together-ai: specifier: ^0.6.0-alpha.3 version: 0.6.0-alpha.3(encoding@0.1.13) @@ -362,6 +371,67 @@ packages: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + /@metaplex-foundation/mpl-token-metadata@3.3.0(@metaplex-foundation/umi@0.9.2): + resolution: {integrity: sha512-t5vO8Wr3ZZZPGrVrGNcosX5FMkwQSgBiVMQMRNDG2De7voYFJmIibD5jdG05EoQ4Y5kZVEiwhYaO+wJB3aO5AA==} + peerDependencies: + '@metaplex-foundation/umi': '>= 0.8.2 < 1' + dependencies: + '@metaplex-foundation/mpl-toolbox': 0.9.4(@metaplex-foundation/umi@0.9.2) + '@metaplex-foundation/umi': 0.9.2 + dev: false + + /@metaplex-foundation/mpl-toolbox@0.9.4(@metaplex-foundation/umi@0.9.2): + resolution: {integrity: sha512-fd6JxfoLbj/MM8FG2x91KYVy1U6AjBQw4qjt7+Da3trzQaWnSaYHDcYRG/53xqfvZ9qofY1T2t53GXPlD87lnQ==} + peerDependencies: + '@metaplex-foundation/umi': '>= 0.8.2 < 1' + dependencies: + '@metaplex-foundation/umi': 0.9.2 + dev: false + + /@metaplex-foundation/umi-options@0.8.9: + resolution: {integrity: sha512-jSQ61sZMPSAk/TXn8v8fPqtz3x8d0/blVZXLLbpVbo2/T5XobiI6/MfmlUosAjAUaQl6bHRF8aIIqZEFkJiy4A==} + dev: false + + /@metaplex-foundation/umi-public-keys@0.8.9: + resolution: {integrity: sha512-CxMzN7dgVGOq9OcNCJe2casKUpJ3RmTVoOvDFyeoTQuK+vkZ1YSSahbqC1iGuHEtKTLSjtWjKvUU6O7zWFTw3Q==} + dependencies: + '@metaplex-foundation/umi-serializers-encodings': 0.8.9 + dev: false + + /@metaplex-foundation/umi-serializers-core@0.8.9: + resolution: {integrity: sha512-WT82tkiYJ0Qmscp7uTj1Hz6aWQPETwaKLAENAUN5DeWghkuBKtuxyBKVvEOuoXerJSdhiAk0e8DWA4cxcTTQ/w==} + dev: false + + /@metaplex-foundation/umi-serializers-encodings@0.8.9: + resolution: {integrity: sha512-N3VWLDTJ0bzzMKcJDL08U3FaqRmwlN79FyE4BHj6bbAaJ9LEHjDQ9RJijZyWqTm0jE7I750fU7Ow5EZL38Xi6Q==} + dependencies: + '@metaplex-foundation/umi-serializers-core': 0.8.9 + dev: false + + /@metaplex-foundation/umi-serializers-numbers@0.8.9: + resolution: {integrity: sha512-NtBf1fnVNQJHFQjLFzRu2i9GGnigb9hOm/Gfrk628d0q0tRJB7BOM3bs5C61VAs7kJs4yd+pDNVAERJkknQ7Lg==} + dependencies: + '@metaplex-foundation/umi-serializers-core': 0.8.9 + dev: false + + /@metaplex-foundation/umi-serializers@0.9.0: + resolution: {integrity: sha512-hAOW9Djl4w4ioKeR4erDZl5IG4iJdP0xA19ZomdaCbMhYAAmG/FEs5khh0uT2mq53/MnzWcXSUPoO8WBN4Q+Vg==} + dependencies: + '@metaplex-foundation/umi-options': 0.8.9 + '@metaplex-foundation/umi-public-keys': 0.8.9 + '@metaplex-foundation/umi-serializers-core': 0.8.9 + '@metaplex-foundation/umi-serializers-encodings': 0.8.9 + '@metaplex-foundation/umi-serializers-numbers': 0.8.9 + dev: false + + /@metaplex-foundation/umi@0.9.2: + resolution: {integrity: sha512-9i4Acm4pruQfJcpRrc2EauPBwkfDN0I9QTvJyZocIlKgoZwD6A6wH0PViH1AjOVG5CQCd1YI3tJd5XjYE1ElBw==} + dependencies: + '@metaplex-foundation/umi-options': 0.8.9 + '@metaplex-foundation/umi-public-keys': 0.8.9 + '@metaplex-foundation/umi-serializers': 0.9.0 + dev: false + /@mozilla/readability@0.5.0: resolution: {integrity: sha512-Z+CZ3QaosfFaTqvhQsIktyGrjFjSC0Fa4EMph4mqKnWhmyoGICsV/8QK+8HpXut6zV7zwfWwqDmEjtk1Qf6EgQ==} engines: {node: '>=14.0.0'} diff --git a/tsconfig.json b/tsconfig.json index 059fdda..dd589d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": false, "strict": false, - "target": "ES2017", + "target": "ES2020", "lib": [ "dom", "dom.iterable",