Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/app/api/price/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server"

async function GET() {
try {
const response = await fetch(
"https://api.jup.ag/price/v3?ids=So11111111111111111111111111111111111111112",
{
method: "GET",
headers: {
"x-api-key": process.env.BIRDEYE_KEY || "",
},
},
)

if (!response.ok) {
throw new Error(`Jupiter API returned status ${response.status}`)
}

const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error("Error in price API route:", error)
return NextResponse.json(
{ error: "Failed to fetch SOL price" },
{ status: 502 },
)
}
}

const maxDuration = 30
const revalidate = 60 // Cache price for 1 minute

export { GET, maxDuration, revalidate }
7 changes: 6 additions & 1 deletion src/components/pages/SolFeesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,17 @@ const Providers = ({ children }: { children: ReactNode }) => {

const SolFeesApp = () => {
const [address, setAddress] = useState<string | null>(null)
const [timeframe, setTimeframe] = useState<number>(0)
const {
addWallet: addAnotherWallet,
error,
isLoading,
progress,
reset: resetResult,
summary,
state,
wallets,
} = useTransactions(address)
} = useTransactions(address, timeframe)
const solPrice = useSolPrice()

const addWallet = () => {
Expand All @@ -57,6 +59,7 @@ const SolFeesApp = () => {
const reset = () => {
resetResult()
setAddress(null)
setTimeframe(0)
}

const [appReady, setAppReady] = useState(false)
Expand Down Expand Up @@ -95,6 +98,7 @@ const SolFeesApp = () => {
<WalletForm
reset={reset}
setAddress={setAddress}
setTimeframe={setTimeframe}
wallets={wallets.length}
/>
</FadeInOutTransition>
Expand Down Expand Up @@ -142,6 +146,7 @@ const SolFeesApp = () => {
summary={summary as WalletResult}
wallets={wallets}
solPrice={solPrice}
isLoading={isLoading}
/>
</div>
<About className="mt-12" />
Expand Down
33 changes: 33 additions & 0 deletions src/components/templates/Result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ResultProps = {
summary: WalletResult
wallets: string[]
solPrice: number | null
isLoading?: boolean
}

const Result: React.FC<ResultProps> = ({
Expand All @@ -44,6 +45,7 @@ const Result: React.FC<ResultProps> = ({
summary,
wallets,
solPrice,
isLoading,
}) => {
// Fallback until new event tracking is available
const plausible = (...args: any[]) => undefined
Expand Down Expand Up @@ -102,8 +104,39 @@ const Result: React.FC<ResultProps> = ({
wallets.length,
)

if (data.txCount === 0) {
return (
<div className={clx("text-center py-12 px-4", className)}>
<p className="flex justify-center items-center mb-8 leading-tight text-slate-500 text-lg">
<IoInformationCircle className="inline mr-2 text-solana-purple" size={32} />
<span>No transactions found for this wallet in the selected timeframe.</span>
</p>
<div className="flex gap-2 justify-center">
<Button color="primary" size="sm" onClick={handleResetClick}>
<MdSkipPrevious size={20} />
Restart
</Button>
<Button color="primary" size="sm" onClick={handleAddWalletClick}>
<MdPlaylistAdd size={20} />
Add Wallet
</Button>
</div>
</div>
)
}

return (
<div className={className}>
{isLoading ? (
<div className="w-full mb-4 p-3 bg-purple-50 rounded-lg border border-purple-100 flex items-center justify-between animate-pulse">
<span className="text-xs font-semibold text-purple-700 uppercase tracking-wider">
Scanning transactions...
</span>
<span className="text-xs font-semibold text-purple-700">
{data.txCount} loaded
</span>
</div>
) : null}
{(data.txCount as number) >= TX_CAP ? (
<p className="flex justify-center items-center mb-2 leading-tight text-blue-500 text-base">
<IoInformationCircle className="inline mr-2" size={32} />
Expand Down
31 changes: 30 additions & 1 deletion src/components/templates/WalletForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,28 @@ const AddressInput: React.FC<AddressInputProps> = ({
type WalletFormProps = {
reset: () => void
setAddress: (address: string) => void
setTimeframe: (timeframe: number) => void
wallets: number
}

const timeframes = [
{ label: "1 Hour", value: 1 },
{ label: "24 Hours", value: 24 },
{ label: "7 Days", value: 168 },
{ label: "All Time", value: 0 },
]

const WalletForm: React.FC<WalletFormProps> = ({
reset,
setAddress,
setTimeframe,
wallets,
}) => {
// Fallback until new event tracking is available
const plausible = (...args: any[]) => undefined
const [value, setValue] = useState("")
const [isValid, setIsValid] = useState(true)
const [localTimeframe, setLocalTimeframe] = useState<number>(0)
const { publicKey, disconnect: disconnectWallet } = useWallet()

useEffect(() => {
Expand All @@ -123,23 +133,42 @@ const WalletForm: React.FC<WalletFormProps> = ({
plausible("Submit", {
props: { Type: isSolanaDomain(value) ? "Domain" : "Address" },
})
setTimeframe(localTimeframe)
setAddress(value)
}
}

useEffect(() => {
if (publicKey) {
plausible("Submit", { props: { Type: "Wallet Connect" } })
setTimeframe(localTimeframe)
setAddress(publicKey.toString())
disconnectWallet()
}
}, [disconnectWallet, plausible, publicKey, setAddress])
}, [disconnectWallet, plausible, publicKey, setAddress, setTimeframe, localTimeframe])

return (
<form
className="max-w-md px-2 grid grid-cols-1 sm:grid-cols-2 gap-2"
onSubmit={handleFormSubmit}
>
<div className="order-1 sm:col-span-2 flex justify-center gap-2 mb-2">
{timeframes.map((tf) => (
<button
key={tf.value}
type="button"
onClick={() => setLocalTimeframe(tf.value)}
className={clx(
"px-3 py-1 text-xs font-semibold rounded-full border transition-all duration-200",
localTimeframe === tf.value
? "bg-solana-purple border-solana-purple text-white shadow-md scale-105"
: "bg-white border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300"
)}
>
{tf.label}
</button>
))}
</div>
<AddressInput
onClearClick={() => plausible("Clear click")}
onPasteClick={() => plausible("Paste click")}
Expand Down
7 changes: 3 additions & 4 deletions src/hooks/useSolPrice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@ const useSolPrice = () => {
}

try {
const response = await fetch(
"https://lite-api.jup.ag/price/v2?ids=So11111111111111111111111111111111111111112",
)
const response = await fetch("/api/price")
if (!response.ok) {
throw new Error("Failed to fetch SOL price")
}
const body = await response.json()
setSolPrice(body.data.So11111111111111111111111111111111111111112.price)
const solData = body.So11111111111111111111111111111111111111112
setSolPrice(solData ? Number(solData.usdPrice) : null)
setFetchFailed(false)
} catch (err) {
console.error("Error fetching SOL price:", err)
Expand Down
46 changes: 40 additions & 6 deletions src/hooks/useTransactions/useTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,24 @@ const E_TRY_AGAIN_BEFORE =

const fetchAllTransactions = async ({
address,
timeframe,
onBatch,
setProgress,
}: {
address: string
timeframe: number
onBatch: (batch: HeliusParsedTransaction[]) => void
setProgress: Function
}) => {
let includedSignatures = new Set()
let before: string | null = null
let result: Array<HeliusParsedTransaction> = []

const cutoffTimeSecs =
timeframe > 0
? Math.floor((Date.now() - timeframe * 60 * 60 * 1000) / 1000)
: 0

while (true) {
const partial: HeliusParsedTransactionResponse = await fetchTransactions(
address,
Expand All @@ -53,13 +62,20 @@ const fetchAllTransactions = async ({

before = partial[partial.length - 1].signature

// Add new, non-duplicate transactions to result
partial.forEach((newTx: HeliusParsedTransaction) => {
// Add new, non-duplicate transactions to result, breaking if we cross cutoffTimeSecs
const batch: HeliusParsedTransaction[] = []
for (const newTx of partial) {
if (cutoffTimeSecs > 0 && newTx.timestamp < cutoffTimeSecs) {
if (batch.length > 0) onBatch(batch)
return result
}
if (!includedSignatures.has(newTx.signature)) {
includedSignatures.add(newTx.signature)
result.push(newTx)
batch.push(newTx)
}
})
}
if (batch.length > 0) onBatch(batch)
setProgress(result.length)

// Stop and return if the cap is reached
Expand All @@ -73,7 +89,7 @@ const aggregateTransactions = (
prevResult: WalletResult | null = null,
) => {
let aggregation: TransactionAggregation = {
firstTransactionTS: Number.MAX_SAFE_INTEGER,
firstTransactionTS: transactions.length > 0 ? Number.MAX_SAFE_INTEGER : 0,
failedTransactions: 0,
feesAvg: 0,
feesTotal: 0,
Expand Down Expand Up @@ -132,7 +148,7 @@ const aggregateTransactions = (
return { aggregation, categorizations }
}

function useTransactions(address: string | null) {
function useTransactions(address: string | null, timeframe: number) {
const initialState: TransactionFetchingStatus = {
error: null,
isLoading: false,
Expand Down Expand Up @@ -232,11 +248,29 @@ function useTransactions(address: string | null) {
// Timeout prevents animations from overlapping
setTimeout(async () => {
let transactions: HeliusParsedTransaction[] = []
let allTxs: HeliusParsedTransaction[] = []

try {
transactions = await fetchAllTransactions({
address: fullAddress,
timeframe,
setProgress,
onBatch: (batch) => {
allTxs = [...allTxs, ...batch]
setStatus((prev) => ({
error: null,
isLoading: true,
summary: {
...aggregateTransactions(
fullAddress,
allTxs,
cachedSummary.current,
),
},
state: allTxs.length > 0 ? "done" : "loading",
transactions: allTxs,
}))
},
})
} catch (error) {
setStateError(error as Error)
Expand All @@ -258,7 +292,7 @@ function useTransactions(address: string | null) {
})
}, 250)
})()
}, [address])
}, [address, timeframe])

return { addWallet, progress, reset, ...status, wallets: wallets.current }
}
Expand Down