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
5 changes: 5 additions & 0 deletions .changeset/add-classified-transactions-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solana/react-hooks": minor
---

Add `useClassifiedTransactions` hook for fetching classified Solana transactions with automatic spam filtering, protocol detection, and transaction classification via tx-indexer SDK. The hook supports cursor-based pagination through `oldestSignature` and `hasMore` return values.
176 changes: 176 additions & 0 deletions apps/docs/content/docs/react-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,181 @@ function NonceInfo({ address }: { address: string }) {
}
```

### useClassifiedTransactions

Fetch and classify transactions for a wallet address with automatic spam filtering, protocol detection, and token metadata enrichment:

```tsx
import { useClassifiedTransactions } from "@solana/react-hooks";

function TransactionHistory({ address }: { address: string }) {
const { transactions, isLoading, isError, hasMore, oldestSignature } =
useClassifiedTransactions({
address,
options: { limit: 10 },
});

if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error loading transactions</p>;

return (
<div>
{transactions.map((tx) => (
<div key={tx.tx.signature}>
<p><strong>{tx.classification.primaryType}</strong></p>
{tx.classification.primaryAmount && (
<p>
{tx.classification.primaryAmount.amountUi}{" "}
{tx.classification.primaryAmount.token.symbol}
</p>
)}
{tx.tx.protocol && <p>via {tx.tx.protocol.name}</p>}
</div>
))}
</div>
);
}
```

**Pagination example:**

```tsx
function PaginatedHistory({ address }: { address: string }) {
const [cursor, setCursor] = useState<string>();

const { transactions, oldestSignature, hasMore, isLoading } =
useClassifiedTransactions({
address,
options: { limit: 20, before: cursor },
});

return (
<div>
{transactions.map((tx) => (
<TransactionCard key={tx.tx.signature} tx={tx} />
))}
{hasMore && (
<button
onClick={() => setCursor(oldestSignature ?? undefined)}
disabled={isLoading}
>
Load More
</button>
)}
</div>
);
}
```

**Rate-limited RPC configuration:**

```tsx
// Conservative settings for public RPCs (e.g., devnet)
const { transactions } = useClassifiedTransactions({
address,
options: {
limit: 5,
overfetchMultiplier: 1,
transactionConcurrency: 1,
},
swr: {
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
});
```

**Options:**

| Option | Default | Description |
| --- | --- | --- |
| `limit` | `10` | Max transactions per request |
| `before` | — | Pagination cursor (oldest signature from previous request) |
| `until` | — | Stop fetching at this signature |
| `cluster` | auto | Override auto-detected cluster (`'mainnet-beta'` \| `'devnet'` \| `'testnet'`) |
| `filterSpam` | `true` | Filter out spam/dust transactions |
| `includeTokenAccounts` | `false` | Query ATAs for incoming token transfers |
| `maxTokenAccounts` | `5` | Max ATAs to query when `includeTokenAccounts` is enabled |
| `enrichTokenMetadata` | `true` | Add token symbols and names |
| `enrichNftMetadata` | `true` | Add NFT metadata (requires DAS RPC) |
| `overfetchMultiplier` | `1` | Signature overfetch multiplier (increase for high-spam wallets) |
| `minPageSize` | `20` | Min signatures per iteration |
| `transactionConcurrency` | `1` | Parallel transaction fetches |

**Response Structure:**

Each item in the `transactions` array is a `ClassifiedTransaction`:

```ts
interface ClassifiedTransaction {
// Raw transaction data
tx: {
signature: string;
slot: number | bigint;
blockTime: number | bigint | null;
fee?: number;
err: any | null;
programIds: string[];
protocol: {
id: string;
name: string; // e.g. "Jupiter", "Raydium"
iconUrl?: string;
} | null;
memo?: string | null;
};

// High-level classification
classification: {
primaryType:
| "transfer" | "swap" | "nft_purchase" | "nft_sale"
| "nft_mint" | "stake_deposit" | "stake_withdraw"
| "airdrop" | "bridge_in" | "bridge_out"
| "privacy_deposit" | "privacy_withdraw" // Privacy Cash
| "fee_only" | "other";
primaryAmount: MoneyAmount | null; // Main amount
secondaryAmount?: MoneyAmount | null; // e.g., swap output
sender?: string | null;
receiver?: string | null;
counterparty: {
type: "person" | "merchant" | "exchange" | "protocol" | "own_wallet" | "unknown";
address: string;
name?: string;
} | null;
confidence: number; // 0-1
metadata?: Record<string, any>; // e.g., nft_mint, nft_name
};

// Individual token/SOL movements
legs: Array<{
accountId: string;
side: "debit" | "credit"; // debit = out, credit = in
amount: MoneyAmount;
role: "sent" | "received" | "fee" | "reward" | "protocol_deposit" | "protocol_withdraw" | "unknown";
}>;
}

interface MoneyAmount {
token: {
mint: string;
symbol: string; // e.g. "SOL", "USDC"
name?: string;
decimals: number;
logoURI?: string;
};
amountRaw: string; // Raw amount (e.g., "1000000000")
amountUi: number; // Human-readable (e.g., 1.0)
fiat?: {
currency: "USD" | "EUR";
amount: number;
pricePerUnit: number;
};
}
```

**Understanding Legs:**

Legs are the individual token movements within a transaction. A simple SOL transfer has 2 legs (sender debit, receiver credit), while a swap has 4+ legs (token out, token in, fees). Use `classification.primaryAmount` for high-level summaries, and `legs` for granular movement details.

## Transfer Hooks

### useSolTransfer
Expand Down Expand Up @@ -646,6 +821,7 @@ export function App() {
| `useAccount` | Fetch and watch account |
| `useLookupTable` | Fetch lookup table |
| `useNonceAccount` | Fetch nonce account |
| `useClassifiedTransactions` | Fetch classified transaction history |
| `useProgramAccounts` | Query program accounts |

### Transaction Hooks
Expand Down
2 changes: 2 additions & 0 deletions examples/vite-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Suspense } from 'react';
import { AccountInspectorCard } from './components/AccountInspectorCard.tsx';
import { AirdropCard } from './components/AirdropCard.tsx';
import { BalanceCard } from './components/BalanceCard.tsx';
import { ClassifiedTransactionsCard } from './components/ClassifiedTransactionsCard.tsx';
import { ClusterStatusCard } from './components/ClusterStatusCard.tsx';
import { LatestBlockhashCard } from './components/LatestBlockhashCard.tsx';
import { ProgramAccountsCard } from './components/ProgramAccountsCard.tsx';
Expand Down Expand Up @@ -95,6 +96,7 @@ function DemoApp() {
fallback={<div className="log-panel text-sm text-muted-foreground">Loading queries…</div>}
>
<div className="grid gap-6 lg:grid-cols-2">
<ClassifiedTransactionsCard />
<LatestBlockhashCard />
<ProgramAccountsCard />
<SimulateTransactionCard />
Expand Down
145 changes: 145 additions & 0 deletions examples/vite-react/src/components/ClassifiedTransactionsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useClassifiedTransactions, useWallet } from '@solana/react-hooks';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';

import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Input } from './ui/input';

function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return JSON.stringify(error);
}

export function ClassifiedTransactionsCard() {
const wallet = useWallet();
const [address, setAddress] = useState('');
const [selectedTxIndex, setSelectedTxIndex] = useState<number | null>(null);

// Track if we've auto-filled from wallet to avoid overwriting user input
const hasAutoFilled = useRef(false);

useEffect(() => {
if (wallet.status === 'connected' && !hasAutoFilled.current && address === '') {
setAddress(wallet.session.account.address.toString());
hasAutoFilled.current = true;
}
}, [wallet, address]);

const trimmedAddress = address.trim();
const { transactions, isLoading, isError, error } = useClassifiedTransactions({
address: trimmedAddress === '' ? undefined : trimmedAddress,
options: {
limit: 5,
filterSpam: true,
},
swr: {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
},
});

const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setAddress(event.target.value);
setSelectedTxIndex(null);
};

const selectedTxJson = useMemo(() => {
if (selectedTxIndex === null || !transactions[selectedTxIndex]) {
return null;
}
return JSON.stringify(transactions[selectedTxIndex], (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2);
}, [selectedTxIndex, transactions]);

return (
<Card className="lg:col-span-2">
<CardHeader>
<div className="space-y-1.5">
<CardTitle>Classified Transactions</CardTitle>
<CardDescription>
Test the <code>useClassifiedTransactions</code> hook. Fetches and classifies transactions with
spam filtering and protocol detection.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<label htmlFor="tx-address">Wallet Address</label>
<Input
autoComplete="off"
id="tx-address"
onChange={handleChange}
placeholder="Base58 address"
value={address}
/>
</div>

<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Status:</span>
<span className="font-medium text-foreground">
{isLoading ? 'Loading...' : isError ? 'Error' : `${transactions.length} transactions`}
</span>
</div>

{isError && error ? (
<span aria-live="polite" className="status-badge" data-state="error">
{formatError(error)}
</span>
) : null}

{transactions.length > 0 ? (
<div className="space-y-3">
{transactions.map((tx, index) => (
<button
type="button"
key={String(tx.tx.signature)}
className={`w-full cursor-pointer rounded-md border p-3 text-left text-sm transition-colors hover:bg-muted/50 ${selectedTxIndex === index ? 'border-primary bg-muted/30' : ''}`}
onClick={() => setSelectedTxIndex(selectedTxIndex === index ? null : index)}
>
<div className="flex items-center justify-between">
<span className="font-medium">{tx.classification.primaryType}</span>
<span className="text-xs text-muted-foreground">
{tx.classification.confidence.toFixed(2)} confidence
</span>
</div>
{tx.classification.primaryAmount && (
<div className="mt-1 text-muted-foreground">
{tx.classification.primaryAmount.amountUi.toFixed(4)}{' '}
{tx.classification.primaryAmount.token.symbol}
</div>
)}
<div className="mt-1 font-mono text-xs text-muted-foreground">
{String(tx.tx.signature).slice(0, 20)}...
</div>
{tx.tx.protocol && (
<div className="mt-1 text-xs">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-primary">
{tx.tx.protocol.name}
</span>
</div>
)}
</button>
))}
</div>
) : null}

{selectedTxJson !== null ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Transaction Structure</span>
<Button variant="ghost" size="sm" onClick={() => setSelectedTxIndex(null)}>
Close
</Button>
</div>
<pre className="max-h-96 overflow-auto rounded bg-muted p-3 text-xs">{selectedTxJson}</pre>
</div>
) : null}
</CardContent>
</Card>
);
}
Loading
Loading