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
122 changes: 122 additions & 0 deletions toolkits/sign-with-wallet-adapter/react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Wallet Adapter + Light Token (React)

Wallet Adapter handles wallet connection and transaction signing. You build transactions with light-token instructions and the connected wallet signs them client-side:

1. Connect wallet via Wallet Adapter
2. Build unsigned transaction
3. Sign transaction using connected wallet (Phantom, Backpack, Solflare, etc.)
4. Send signed transaction to RPC

Light Token gives you rent-free token accounts on Solana. Light-token accounts hold balances from any light, SPL, or Token-2022 mint.


## What you will implement

| | SPL | Light Token |
| --- | --- | --- |
| [**Transfer**](#hooks) | `transferChecked()` | `createTransferInterfaceInstruction()` |
| [**Wrap**](#hooks) | N/A | `createWrapInstruction()` |
| [**Get balance**](#hooks) | `getAccount()` | `getAtaInterface()` |
| [**Transaction history**](#hooks) | `getSignaturesForAddress()` | `getSignaturesForOwnerInterface()` |

### Source files

#### Hooks

- **[useTransfer.ts](src/hooks/useTransfer.ts)** — Transfer light-tokens between wallets. Auto-loads cold balance before sending.
- **[useWrap.ts](src/hooks/useWrap.ts)** — Wrap SPL or T22 tokens into light-token associated token account. Auto-detects token program.
- **[useUnwrap.ts](src/hooks/useUnwrap.ts)** — Unwrap light-token associated token account back to SPL or T22. Hook only, not wired into UI.
- **[useLightBalance.ts](src/hooks/useLightBalance.ts)** — Query hot, cold, and unified Light Token balance for a single mint.
- **[useUnifiedBalance.ts](src/hooks/useUnifiedBalance.ts)** — Query balance breakdown: SOL, SPL, Token 2022, light-token hot, and compressed cold.
- **[useTransactionHistory.ts](src/hooks/useTransactionHistory.ts)** — Fetch transaction history for light-token operations.

#### Components

- **[TransferForm.tsx](src/components/sections/TransferForm.tsx)** — Single "Send" button. Routes by token type: light-token -> light-token, or SPL/Token 2022 are wrapped then transfered in one transaction.
- **[TransactionHistory.tsx](src/components/sections/TransactionHistory.tsx)** — Recent light-token interface transactions with explorer links.
- **[WalletInfo.tsx](src/components/sections/WalletInfo.tsx)** — Wallet address display.
- **[TransactionStatus.tsx](src/components/sections/TransactionStatus.tsx)** — Last transaction signature with explorer link.

> Light Token is currently deployed on **devnet**. The interface PDA pattern described here applies to mainnet.

## Before you start

### Your mint needs an SPL interface PDA

The interface PDA enables interoperability between SPL/T22 and light-token. It holds SPL/T22 tokens when they're wrapped into light-token format.

**Check if your mint has one:**

```typescript
import { getSplInterfaceInfos } from "@lightprotocol/compressed-token";

const infos = await getSplInterfaceInfos(rpc, mint);
const hasInterface = infos.some((info) => info.isInitialized);
```

**Register one if it doesn't:**

```bash
# For an existing SPL or T22 mint (from scripts/)
cd ../scripts && npm run register:spl-interface <mint-address>
```

Or in code via `createSplInterface(rpc, payer, mint)`. Works with both SPL Token and Token-2022 mints.

**Example: wrapping devnet USDC.** If you have devnet USDC (`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`), register its interface PDA first, then wrap it into a light-token associated token account. Set `TEST_MINT` in `.env` to the USDC mint address.

## Setup

```bash
npm install
cp .env.example .env
# Fill in your credentials
```

### Environment variables

| Variable | Description |
| -------- | ----------- |
| `VITE_HELIUS_RPC_URL` | Helius RPC endpoint (e.g. `https://devnet.helius-rpc.com?api-key=...`). Required for ZK compression indexing. |

### Setup helpers (local keypair)

Setup scripts live in [`scripts/`](../scripts/). They use the Solana CLI keypair at `~/.config/solana/id.json`.

```bash
cd ../scripts
cp .env.example .env # set HELIUS_RPC_URL
```

| Command | What it does |
| ------- | ----------- |
| `npm run mint:spl-and-wrap <recipient> [amount] [decimals]` | Create an SPL or T22 mint with interface PDA, mint tokens, wrap, and transfer to recipient. |
| `npm run mint:spl <mint> <recipient> [amount] [decimals]` | Mint additional SPL or T22 tokens to an existing mint. |
| `npm run register:spl-interface <mint>` | Register an interface PDA on an existing SPL or T22 mint. Required for wrap/unwrap. |

## Quick start

```bash
# 1. Create a test mint with interface PDA + fund your wallet
cd ../scripts && npm run mint:spl-and-wrap <your-wallet-address>

# 2. Start the dev server
cd ../react && npm run dev
```

Then in the browser:
1. Connect your wallet via the Wallet Adapter modal
2. Select a light-token balance from the dropdown
3. Enter a recipient address and amount
4. Click "Send" — the app transfers directly
5. Select an SPL balance — the app wraps to light-token then transfers (two signing prompts)

## Tests

```bash
# Unit tests (no network)
pnpm test

# Integration tests (devnet)
VITE_HELIUS_RPC_URL=https://devnet.helius-rpc.com?api-key=... pnpm test:integration
```
13 changes: 13 additions & 0 deletions toolkits/sign-with-wallet-adapter/react/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Wallet Adapter Light Token</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions toolkits/sign-with-wallet-adapter/react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "react-wallet-adapter-light-token",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@lightprotocol/compressed-token": "beta",
"@lightprotocol/stateless.js": "beta",
"@solana/spl-token": "^0.4.13",
"@solana/wallet-adapter-react": "^0.15.35",
"@solana/wallet-adapter-react-ui": "^0.9.35",
"@solana/web3.js": "1.98.4",
"buffer": "^6.0.3",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.1.1",
"@types/react-dom": "^19.1.1",
"@vitejs/plugin-react": "^4.3.4",
"happy-dom": "^20.6.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.8.3",
"vite": "^6.0.11",
"vite-plugin-node-polyfills": "^0.25.0",
"vitest": "^4.0.18"
}
}
95 changes: 95 additions & 0 deletions toolkits/sign-with-wallet-adapter/react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
import { useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { useUnifiedBalance } from './hooks/useUnifiedBalance';
import { Header } from './components/ui/Header';
import WalletInfo from './components/sections/WalletInfo';
import TransferForm from './components/sections/TransferForm';
import TransactionStatus from './components/sections/TransactionStatus';
import TransactionHistory from './components/sections/TransactionHistory';

export default function App() {
const { publicKey, connected } = useWallet();
const { balances, isLoading: isLoadingBalances, fetchBalances } = useUnifiedBalance();

const [selectedMint, setSelectedMint] = useState<string>('');
const [txSignature, setTxSignature] = useState<string | null>(null);
const [txError, setTxError] = useState<string | null>(null);

const ownerAddress = publicKey?.toBase58() ?? '';

useEffect(() => {
if (!ownerAddress) {
setSelectedMint('');
return;
}

const loadBalances = async () => {
await fetchBalances(ownerAddress);
};

loadBalances();
}, [ownerAddress, fetchBalances]);

useEffect(() => {
if (balances.length > 0 && !selectedMint) {
setSelectedMint(balances[0].mint);
}
}, [balances, selectedMint]);

const handleTransferSuccess = async (signature: string) => {
setTxSignature(signature);
setTxError(null);
await fetchBalances(ownerAddress);
};

const handleTransferError = (error: string) => {
setTxError(error);
setTxSignature(null);
};

return (
<div className="bg-[#E0E7FF66] min-h-screen">
<Header />
{connected && publicKey ? (
<section className="w-full p-8">
<div className="flex items-center justify-between mb-8">
<WalletMultiButton />
</div>

<div className="flex justify-center">
<div className="w-full max-w-2xl">
<WalletInfo address={ownerAddress} />

<TransferForm
ownerAddress={ownerAddress}
selectedMint={selectedMint}
onMintChange={setSelectedMint}
balances={balances}
isLoadingBalances={isLoadingBalances}
onTransferSuccess={handleTransferSuccess}
onTransferError={handleTransferError}
/>

<TransactionStatus signature={txSignature} error={txError} />

<TransactionHistory ownerAddress={ownerAddress} refreshTrigger={txSignature} />
</div>
</div>
</section>
) : (
<section className="w-full flex flex-col justify-center items-center h-[calc(100vh-60px)] px-4">
<div className="text-center max-w-md">
<h1 className="text-3xl md:text-4xl font-semibold text-gray-900 mb-3">
Send Tokens
</h1>
<p className="text-gray-600 mb-8">
Send light tokens to any Solana address instantly.
</p>
<WalletMultiButton />
</div>
</section>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState } from 'react';
import { ClipboardIcon, CheckIcon } from '@heroicons/react/24/outline';

interface CopyButtonProps {
text: string;
label?: string;
}

export default function CopyButton({ text, label }: CopyButtonProps) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

return (
<button
onClick={handleCopy}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title={`Copy ${label || 'text'}`}
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<ClipboardIcon className="h-4 w-4" />
)}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ReactNode } from 'react';

interface SectionProps {
name: string;
children: ReactNode;
}

export default function Section({ name, children }: SectionProps) {
return (
<div className="section">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{name}</h2>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { useTransactionHistory } from '../../hooks/useTransactionHistory';
import Section from '../reusables/Section';
import CopyButton from '../reusables/CopyButton';

interface TransactionHistoryProps {
ownerAddress: string;
refreshTrigger?: string | null;
}

export default function TransactionHistory({ ownerAddress, refreshTrigger }: TransactionHistoryProps) {
const { transactions, isLoading, error, fetchTransactionHistory } = useTransactionHistory();
const [isExpanded, setIsExpanded] = useState(false);

useEffect(() => {
if (ownerAddress) {
fetchTransactionHistory(ownerAddress);
}
}, [ownerAddress, refreshTrigger, fetchTransactionHistory]);

if (!ownerAddress) return null;

return (
<Section name="Transaction History">
{isLoading && <p className="text-sm text-gray-500">Loading...</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
{!isLoading && transactions.length === 0 && !error && (
<p className="text-sm text-gray-500">No transactions found.</p>
)}
{transactions.length > 0 && (
<div className="space-y-2">
{transactions.slice(0, isExpanded ? undefined : 5).map((tx) => (
<div
key={tx.signature}
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0"
>
<div>
<p className="font-mono text-xs text-gray-700">
{tx.signature.slice(0, 16)}...{tx.signature.slice(-8)}
</p>
{tx.timestamp && (
<p className="text-xs text-gray-400">
{new Date(tx.timestamp).toLocaleString()}
</p>
)}
</div>
<div className="flex items-center gap-2">
<CopyButton text={tx.signature} label="Signature" />
<a
href={`https://explorer.solana.com/tx/${tx.signature}?cluster=devnet`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-indigo-600 hover:text-indigo-800"
>
Explorer
</a>
</div>
</div>
))}
{transactions.length > 5 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-xs text-indigo-600 hover:text-indigo-800"
>
{isExpanded ? 'Show less' : `Show all (${transactions.length})`}
</button>
)}
</div>
)}
</Section>
);
}
Loading