From 0e4713bc4ff7cc65dbfbcb6526df28a73dcec11b Mon Sep 17 00:00:00 2001 From: "Klaus T." Date: Thu, 26 Feb 2026 03:05:09 +0000 Subject: [PATCH 1/4] feat: add sign-with-wallet-adapter React example Adds a Wallet Adapter equivalent of the existing Privy example. Swaps the signing layer from Privy SDK to @solana/wallet-adapter-react while keeping identical Light Token SDK usage (transfer, wrap, unwrap). Changes from Privy example: - ConnectionProvider + WalletProvider + WalletModalProvider (replaces PrivyProvider) - useWallet().signTransaction (replaces useSignTransaction from Privy) - signAndSendBatches takes signTransaction(Transaction) -> Transaction - WalletMultiButton for connect/disconnect (replaces Privy login/logout) - Removed wallet selector (single connected wallet) - Fixed hot/cold balance fetch order (cold before hot) so compressed-only mints also get hot balance lookup 26 unit tests passing, build clean. Co-Authored-By: Claude Opus 4.6 --- .../sign-with-wallet-adapter/react/README.md | 30 +++ .../sign-with-wallet-adapter/react/index.html | 13 ++ .../react/package.json | 38 ++++ .../react/src/App.tsx | 95 +++++++++ .../src/components/reusables/CopyButton.tsx | 31 +++ .../src/components/reusables/Section.tsx | 15 ++ .../sections/TransactionHistory.tsx | 72 +++++++ .../components/sections/TransactionStatus.tsx | 45 ++++ .../src/components/sections/TransferForm.tsx | 189 +++++++++++++++++ .../src/components/sections/WalletInfo.tsx | 24 +++ .../react/src/components/ui/Header.tsx | 30 +++ .../integration/hooks.integration.test.ts | 44 ++++ .../react/src/hooks/__tests__/mock-rpc.ts | 43 ++++ .../__tests__/useTransactionHistory.test.ts | 141 +++++++++++++ .../src/hooks/__tests__/useTransfer.test.ts | 138 +++++++++++++ .../hooks/__tests__/useUnifiedBalance.test.ts | 192 ++++++++++++++++++ .../src/hooks/__tests__/useUnwrap.test.ts | 145 +++++++++++++ .../react/src/hooks/__tests__/useWrap.test.ts | 156 ++++++++++++++ .../react/src/hooks/index.ts | 6 + .../react/src/hooks/signAndSendBatches.ts | 34 ++++ .../react/src/hooks/useLightBalance.ts | 65 ++++++ .../react/src/hooks/useTransactionHistory.ts | 63 ++++++ .../react/src/hooks/useTransfer.ts | 63 ++++++ .../react/src/hooks/useUnifiedBalance.ts | 159 +++++++++++++++ .../react/src/hooks/useUnwrap.ts | 70 +++++++ .../react/src/hooks/useWrap.ts | 86 ++++++++ .../react/src/index.css | 21 ++ .../react/src/main.tsx | 28 +++ .../react/src/test-setup.ts | 3 + .../react/src/vite-env.d.ts | 9 + .../react/tsconfig.json | 21 ++ .../react/tsconfig.node.json | 10 + .../react/vite.config.ts | 15 ++ .../react/vitest.integration.config.ts | 15 ++ 34 files changed, 2109 insertions(+) create mode 100644 toolkits/sign-with-wallet-adapter/react/README.md create mode 100644 toolkits/sign-with-wallet-adapter/react/index.html create mode 100644 toolkits/sign-with-wallet-adapter/react/package.json create mode 100644 toolkits/sign-with-wallet-adapter/react/src/App.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/components/reusables/CopyButton.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/components/reusables/Section.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionHistory.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionStatus.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/components/sections/TransferForm.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/components/sections/WalletInfo.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/components/ui/Header.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/hooks.integration.test.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/mock-rpc.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransactionHistory.test.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransfer.test.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnifiedBalance.test.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnwrap.test.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/index.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/signAndSendBatches.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/useLightBalance.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/useTransactionHistory.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/useTransfer.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/useUnifiedBalance.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/useUnwrap.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/index.css create mode 100644 toolkits/sign-with-wallet-adapter/react/src/main.tsx create mode 100644 toolkits/sign-with-wallet-adapter/react/src/test-setup.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/src/vite-env.d.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/tsconfig.json create mode 100644 toolkits/sign-with-wallet-adapter/react/tsconfig.node.json create mode 100644 toolkits/sign-with-wallet-adapter/react/vite.config.ts create mode 100644 toolkits/sign-with-wallet-adapter/react/vitest.integration.config.ts diff --git a/toolkits/sign-with-wallet-adapter/react/README.md b/toolkits/sign-with-wallet-adapter/react/README.md new file mode 100644 index 0000000..e9a9275 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/README.md @@ -0,0 +1,30 @@ +# React + Wallet Adapter — Light Token Example + +Send, wrap, and unwrap Light Tokens using `@solana/wallet-adapter-react`. + +## Setup + +```bash +cp .env.example .env +# Fill in VITE_HELIUS_RPC_URL (devnet) + +pnpm install +pnpm dev +``` + +## 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 +``` + +## Stack + +- React 19, Vite, Tailwind v4 +- `@solana/wallet-adapter-react` + `@solana/wallet-adapter-react-ui` +- `@lightprotocol/compressed-token` (unified interface) +- `@solana/web3.js` 1.x diff --git a/toolkits/sign-with-wallet-adapter/react/index.html b/toolkits/sign-with-wallet-adapter/react/index.html new file mode 100644 index 0000000..4532abb --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/index.html @@ -0,0 +1,13 @@ + + + + + + + React Wallet Adapter Light Token + + +
+ + + diff --git a/toolkits/sign-with-wallet-adapter/react/package.json b/toolkits/sign-with-wallet-adapter/react/package.json new file mode 100644 index 0000000..058aeae --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/package.json @@ -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" + } +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/App.tsx b/toolkits/sign-with-wallet-adapter/react/src/App.tsx new file mode 100644 index 0000000..1b77f4e --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/App.tsx @@ -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(''); + const [txSignature, setTxSignature] = useState(null); + const [txError, setTxError] = useState(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 ( +
+
+ {connected && publicKey ? ( +
+
+ +
+ +
+
+ + + + + + + +
+
+
+ ) : ( +
+
+

+ Send Tokens +

+

+ Send light tokens to any Solana address instantly. +

+ +
+
+ )} +
+ ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/components/reusables/CopyButton.tsx b/toolkits/sign-with-wallet-adapter/react/src/components/reusables/CopyButton.tsx new file mode 100644 index 0000000..71258b3 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/components/reusables/CopyButton.tsx @@ -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 ( + + ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/components/reusables/Section.tsx b/toolkits/sign-with-wallet-adapter/react/src/components/reusables/Section.tsx new file mode 100644 index 0000000..2726631 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/components/reusables/Section.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; + +interface SectionProps { + name: string; + children: ReactNode; +} + +export default function Section({ name, children }: SectionProps) { + return ( +
+

{name}

+ {children} +
+ ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionHistory.tsx b/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionHistory.tsx new file mode 100644 index 0000000..1abeff3 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionHistory.tsx @@ -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 ( +
+ {isLoading &&

Loading...

} + {error &&

{error}

} + {!isLoading && transactions.length === 0 && !error && ( +

No transactions found.

+ )} + {transactions.length > 0 && ( +
+ {transactions.slice(0, isExpanded ? undefined : 5).map((tx) => ( +
+
+

+ {tx.signature.slice(0, 16)}...{tx.signature.slice(-8)} +

+ {tx.timestamp && ( +

+ {new Date(tx.timestamp).toLocaleString()} +

+ )} +
+ +
+ ))} + {transactions.length > 5 && ( + + )} +
+ )} +
+ ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionStatus.tsx b/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionStatus.tsx new file mode 100644 index 0000000..709cdc2 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransactionStatus.tsx @@ -0,0 +1,45 @@ +import Section from '../reusables/Section'; +import CopyButton from '../reusables/CopyButton'; + +interface TransactionStatusProps { + signature: string | null; + error: string | null; +} + +export default function TransactionStatus({ signature, error }: TransactionStatusProps) { + if (!signature && !error) return null; + + return ( +
+ {signature && ( +
+
+
+

Success!

+

+ {signature.slice(0, 20)}...{signature.slice(-20)} +

+
+ +
+
+ )} + {error && ( +
+

Error

+

{error}

+
+ )} +
+ ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransferForm.tsx b/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransferForm.tsx new file mode 100644 index 0000000..439645d --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/components/sections/TransferForm.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react'; +import { useWallet } from '@solana/wallet-adapter-react'; +import { useTransfer } from '../../hooks/useTransfer'; +import type { TokenBalance } from '../../hooks/useUnifiedBalance'; +import CopyButton from '../reusables/CopyButton'; +import Section from '../reusables/Section'; + +interface TransferFormProps { + ownerAddress: string; + selectedMint: string; + onMintChange: (mint: string) => void; + balances: TokenBalance[]; + isLoadingBalances: boolean; + onTransferSuccess: (signature: string) => void; + onTransferError: (error: string) => void; +} + +function formatBigint(value: bigint, decimals: number): string { + const divisor = BigInt(10 ** decimals); + const whole = value / divisor; + const fraction = value % divisor; + const fractionStr = fraction.toString().padStart(decimals, '0').replace(/0+$/, ''); + return fractionStr ? `${whole}.${fractionStr}` : whole.toString(); +} + +function totalBalance(balance: TokenBalance): bigint { + if (balance.isNative) return balance.spl; + return balance.unified + balance.spl + balance.t22; +} + +export default function TransferForm({ + ownerAddress, + selectedMint, + onMintChange, + balances, + isLoadingBalances, + onTransferSuccess, + onTransferError, +}: TransferFormProps) { + const { signTransaction } = useWallet(); + const { transfer } = useTransfer(); + + const [recipientAddress, setRecipientAddress] = useState(''); + const [amount, setAmount] = useState('1'); + const [isLoading, setIsLoading] = useState(false); + + const handleTransfer = async () => { + if (!ownerAddress || !selectedMint || !recipientAddress) return; + + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum <= 0) { + alert('Please enter a valid amount'); + return; + } + + if (!signTransaction) { + onTransferError('Wallet does not support signTransaction'); + return; + } + + const selectedToken = balances.find(b => b.mint === selectedMint); + if (!selectedToken || selectedToken.unified === 0n) return; + + setIsLoading(true); + + try { + const signature = await transfer({ + params: { + ownerPublicKey: ownerAddress, + mint: selectedMint, + toAddress: recipientAddress, + amount: amountNum, + decimals: selectedToken.decimals, + }, + signTransaction, + }); + + setRecipientAddress(''); + setAmount('1'); + onTransferSuccess(signature); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('Transfer error:', error); + onTransferError(message); + } finally { + setIsLoading(false); + } + }; + + const selectedBalance = balances.find(b => b.mint === selectedMint); + const canSend = selectedBalance && !selectedBalance.isNative + && selectedBalance.unified > 0n; + + return ( +
+
+ +
+ + {ownerAddress && ( + + )} +
+
+ +
+ +
+ +
+
+ +
+ + setRecipientAddress(e.target.value)} + placeholder="Enter Solana address" + className="input" + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="1" + min="0" + step="0.000001" + className="input" + /> +
+ + +
+ ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/components/sections/WalletInfo.tsx b/toolkits/sign-with-wallet-adapter/react/src/components/sections/WalletInfo.tsx new file mode 100644 index 0000000..89e1587 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/components/sections/WalletInfo.tsx @@ -0,0 +1,24 @@ +import Section from '../reusables/Section'; +import CopyButton from '../reusables/CopyButton'; + +interface WalletInfoProps { + address: string; +} + +export default function WalletInfo({ address }: WalletInfoProps) { + if (!address) return null; + + return ( +
+
+
+

Address

+

+ {address.slice(0, 12)}...{address.slice(-12)} +

+
+ +
+
+ ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/components/ui/Header.tsx b/toolkits/sign-with-wallet-adapter/react/src/components/ui/Header.tsx new file mode 100644 index 0000000..69ffcd5 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/components/ui/Header.tsx @@ -0,0 +1,30 @@ +export function Header() { + return ( +
+
+
+ Light Token + + Wallet Adapter +
+ +
+
+ ); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/hooks.integration.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/hooks.integration.test.ts new file mode 100644 index 0000000..85dd962 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/hooks.integration.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { renderHook, act } from '@testing-library/react'; +import { useUnifiedBalance } from '../../useUnifiedBalance'; +import { useTransactionHistory } from '../../useTransactionHistory'; + +const RPC_URL = import.meta.env.VITE_HELIUS_RPC_URL; + +// Fresh keypair — no token accounts, minimal RPC calls +const TEST_ADDRESS = Keypair.generate().publicKey.toBase58(); + +describe.runIf(RPC_URL)('hooks (devnet integration)', () => { + describe('useUnifiedBalance', () => { + it('fetches SOL balance for empty wallet', async () => { + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(TEST_ADDRESS); + }); + + expect(result.current.isLoading).toBe(false); + expect(Array.isArray(result.current.balances)).toBe(true); + + const sol = result.current.balances.find((b) => b.isNative); + expect(sol).toBeDefined(); + expect(sol!.spl).toBe(0n); + expect(sol!.decimals).toBe(9); + }); + }); + + describe('useTransactionHistory', () => { + it('returns empty for address with no history', async () => { + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(TEST_ADDRESS, 5); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.transactions).toEqual([]); + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/mock-rpc.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/mock-rpc.ts new file mode 100644 index 0000000..3e548bc --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/mock-rpc.ts @@ -0,0 +1,43 @@ +import { vi } from 'vitest'; +import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; + +/** Shared test keypair — use TEST_KEYPAIR.publicKey as ownerPublicKey in tests. */ +export const TEST_KEYPAIR = Keypair.generate(); + +/** Creates a mock RPC object matching the shape returned by `createRpc`. */ +export function createMockRpc() { + return { + getBalance: vi.fn().mockResolvedValue(1_000_000_000), // 1 SOL + getTokenAccountsByOwner: vi.fn().mockResolvedValue({ value: [] }), + getAccountInfo: vi.fn().mockResolvedValue(null), + getCompressedTokenBalancesByOwnerV2: vi.fn().mockResolvedValue({ + value: { items: [] }, + }), + getSignaturesForOwnerInterface: vi.fn().mockResolvedValue({ + signatures: [], + }), + getLatestBlockhash: vi.fn().mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }), + sendRawTransaction: vi.fn().mockResolvedValue('mock-signature-abc123'), + confirmTransaction: vi.fn().mockResolvedValue({}), + }; +} + +/** Builds a minimal SPL/T22 token account data buffer (72+ bytes). */ +export function buildTokenAccountData(mint: PublicKey, amount: bigint): Buffer { + const buf = Buffer.alloc(72); + mint.toBuffer().copy(buf, 0); // bytes 0-31: mint + // bytes 32-63: owner (unused in tests) + buf.writeBigUInt64LE(amount, 64); // bytes 64-71: amount + return buf; +} + +/** Creates a mock signTransaction that signs with TEST_KEYPAIR. */ +export function createMockSignTransaction() { + return vi.fn().mockImplementation(async (tx: Transaction) => { + tx.partialSign(TEST_KEYPAIR); + return tx; + }); +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransactionHistory.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransactionHistory.test.ts new file mode 100644 index 0000000..517018a --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransactionHistory.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { createMockRpc } from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, +})); + +import { useTransactionHistory } from '../useTransactionHistory'; + +const OWNER = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useTransactionHistory', () => { + it('returns empty when address is empty', async () => { + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(''); + }); + + expect(result.current.transactions).toEqual([]); + expect(mockRpc.getSignaturesForOwnerInterface).not.toHaveBeenCalled(); + }); + + it('fetches and formats transactions', async () => { + const blockTime = Math.floor(Date.now() / 1000); + mockRpc.getSignaturesForOwnerInterface.mockResolvedValue({ + signatures: [ + { signature: 'sig1', slot: 100, blockTime }, + { signature: 'sig2', slot: 101, blockTime: blockTime + 60 }, + ], + }); + + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(OWNER); + }); + + expect(result.current.transactions).toHaveLength(2); + expect(result.current.transactions[0].signature).toBe('sig1'); + expect(result.current.transactions[0].slot).toBe(100); + expect(result.current.transactions[0].timestamp).toBe( + new Date(blockTime * 1000).toISOString(), + ); + }); + + it('respects limit parameter', async () => { + mockRpc.getSignaturesForOwnerInterface.mockResolvedValue({ + signatures: [ + { signature: 'a', slot: 1, blockTime: 1000 }, + { signature: 'b', slot: 2, blockTime: 2000 }, + { signature: 'c', slot: 3, blockTime: 3000 }, + ], + }); + + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(OWNER, 2); + }); + + expect(result.current.transactions).toHaveLength(2); + expect(result.current.transactions[0].signature).toBe('a'); + expect(result.current.transactions[1].signature).toBe('b'); + }); + + it('handles null blockTime gracefully', async () => { + mockRpc.getSignaturesForOwnerInterface.mockResolvedValue({ + signatures: [{ signature: 'sig-null', slot: 50, blockTime: null }], + }); + + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(OWNER); + }); + + expect(result.current.transactions[0].blockTime).toBe(0); + expect(result.current.transactions[0].timestamp).toBe(''); + }); + + it('sets error state on RPC failure', async () => { + mockRpc.getSignaturesForOwnerInterface.mockRejectedValue( + new Error('Network error'), + ); + + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(OWNER); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.transactions).toEqual([]); + }); + + it('clears error on successful refetch', async () => { + mockRpc.getSignaturesForOwnerInterface.mockRejectedValueOnce( + new Error('fail'), + ); + + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(OWNER); + }); + expect(result.current.error).toBe('fail'); + + mockRpc.getSignaturesForOwnerInterface.mockResolvedValueOnce({ + signatures: [{ signature: 's', slot: 1, blockTime: 1000 }], + }); + + await act(async () => { + await result.current.fetchTransactionHistory(OWNER); + }); + expect(result.current.error).toBeNull(); + expect(result.current.transactions).toHaveLength(1); + }); + + it('handles empty signatures array', async () => { + mockRpc.getSignaturesForOwnerInterface.mockResolvedValue({ + signatures: [], + }); + + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(OWNER); + }); + + expect(result.current.transactions).toEqual([]); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransfer.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransfer.test.ts new file mode 100644 index 0000000..25aa586 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useTransfer.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockSignTransaction, + TEST_KEYPAIR, +} from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, +})); + +const dummyIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), +}); + +const mockCreateTransferInterfaceInstructions = vi.fn(); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + createTransferInterfaceInstructions: (...args: unknown[]) => + mockCreateTransferInterfaceInstructions(...args), +})); + +import { useTransfer } from '../useTransfer'; + +const signTransaction = createMockSignTransaction(); + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getLatestBlockhash.mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }); + mockRpc.sendRawTransaction.mockResolvedValue('tx-sig-transfer'); + signTransaction.mockImplementation(async (tx: any) => { + tx.partialSign(TEST_KEYPAIR); + return tx; + }); + // Default: single batch with one instruction + mockCreateTransferInterfaceInstructions.mockResolvedValue([[dummyIx]]); +}); + +describe('useTransfer', () => { + it('builds, signs, and sends a transfer transaction', async () => { + const { result } = renderHook(() => useTransfer()); + + let sig: string | undefined; + await act(async () => { + sig = await result.current.transfer({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1.5, + decimals: 9, + }, + signTransaction, + }); + }); + + expect(sig).toBe('tx-sig-transfer'); + expect(mockCreateTransferInterfaceInstructions).toHaveBeenCalledOnce(); + expect(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('handles multiple instruction batches', async () => { + mockCreateTransferInterfaceInstructions.mockResolvedValue([ + [dummyIx], + [dummyIx], + ]); + + const { result } = renderHook(() => useTransfer()); + + await act(async () => { + await result.current.transfer({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }); + + expect(signTransaction).toHaveBeenCalledTimes(2); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledTimes(2); + }); + + it('manages isLoading state', async () => { + const { result } = renderHook(() => useTransfer()); + expect(result.current.isLoading).toBe(false); + + await act(async () => { + await result.current.transfer({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('propagates RPC errors', async () => { + mockRpc.sendRawTransaction.mockRejectedValue( + new Error('Transaction failed'), + ); + + const { result } = renderHook(() => useTransfer()); + + await expect( + act(async () => { + await result.current.transfer({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }), + ).rejects.toThrow('Transaction failed'); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnifiedBalance.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnifiedBalance.test.ts new file mode 100644 index 0000000..0093c67 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnifiedBalance.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey } from '@solana/web3.js'; +import { createMockRpc, buildTokenAccountData } from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, +})); + +const mockGetAtaInterface = vi.fn(); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + getAssociatedTokenAddressInterface: () => PublicKey.unique(), + getAtaInterface: (...args: unknown[]) => mockGetAtaInterface(...args), +})); + +// Import after mocks are hoisted +import { useUnifiedBalance } from '../useUnifiedBalance'; + +const OWNER = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'; + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getBalance.mockResolvedValue(2_500_000_000); // 2.5 SOL + mockRpc.getTokenAccountsByOwner.mockResolvedValue({ value: [] }); + mockRpc.getCompressedTokenBalancesByOwnerV2.mockResolvedValue({ + value: { items: [] }, + }); + // Default: getAtaInterface throws (no associated token account exists) + mockGetAtaInterface.mockRejectedValue(new Error('Account not found')); +}); + +describe('useUnifiedBalance', () => { + it('returns empty balances and does not fetch when address is empty', async () => { + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(''); + }); + + expect(result.current.balances).toEqual([]); + expect(mockRpc.getBalance).not.toHaveBeenCalled(); + }); + + it('fetches SOL balance as native entry', async () => { + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const sol = result.current.balances.find((b) => b.isNative); + expect(sol).toBeDefined(); + expect(sol!.spl).toBe(BigInt(2_500_000_000)); + expect(sol!.decimals).toBe(9); + expect(sol!.hot).toBe(0n); + expect(sol!.cold).toBe(0n); + expect(sol!.t22).toBe(0n); + expect(sol!.unified).toBe(0n); + }); + + it('parses SPL token account from raw buffer', async () => { + const mint = PublicKey.unique(); + const data = buildTokenAccountData(mint, 500_000n); + + // First call: SPL accounts, second call: Token 2022 accounts + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ + value: [{ pubkey: PublicKey.unique(), account: { data } }], + }) + .mockResolvedValueOnce({ value: [] }); + + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const spl = result.current.balances.find( + (b) => b.mint === mint.toBase58(), + ); + expect(spl).toBeDefined(); + expect(spl!.spl).toBe(500_000n); + expect(spl!.t22).toBe(0n); + expect(spl!.hot).toBe(0n); + expect(spl!.cold).toBe(0n); + expect(spl!.unified).toBe(0n); + expect(spl!.isNative).toBe(false); + }); + + it('aggregates hot and cold balances into unified', async () => { + const mint = PublicKey.unique(); + + mockRpc.getTokenAccountsByOwner.mockResolvedValue({ value: [] }); + + // Hot balance via getAtaInterface: 300k + mockGetAtaInterface.mockResolvedValue({ + parsed: { amount: 300_000n }, + }); + + // Cold balance: 200k for same mint + mockRpc.getCompressedTokenBalancesByOwnerV2.mockResolvedValue({ + value: { + items: [{ mint, balance: 200_000n }], + }, + }); + + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const entry = result.current.balances.find( + (b) => b.mint === mint.toBase58(), + ); + expect(entry).toBeDefined(); + expect(entry!.hot).toBe(300_000n); + expect(entry!.cold).toBe(200_000n); + expect(entry!.unified).toBe(500_000n); + expect(entry!.t22).toBe(0n); + }); + + it('adds standalone cold balance when no hot ATA exists', async () => { + const coldMint = PublicKey.unique(); + + mockRpc.getTokenAccountsByOwner.mockResolvedValue({ value: [] }); + mockRpc.getCompressedTokenBalancesByOwnerV2.mockResolvedValue({ + value: { + items: [{ mint: coldMint, balance: 750_000n }], + }, + }); + + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const cold = result.current.balances.find( + (b) => b.mint === coldMint.toBase58(), + ); + expect(cold).toBeDefined(); + expect(cold!.cold).toBe(750_000n); + expect(cold!.hot).toBe(0n); + // unified = hot(0) + cold(750k) = 750k + expect(cold!.unified).toBe(750_000n); + expect(cold!.spl).toBe(0n); + expect(cold!.t22).toBe(0n); + }); + + it('sets isLoading during fetch and clears after', async () => { + const { result } = renderHook(() => useUnifiedBalance()); + + expect(result.current.isLoading).toBe(false); + + let resolveBalance!: (v: number) => void; + mockRpc.getBalance.mockReturnValue( + new Promise((r) => { + resolveBalance = r; + }), + ); + + const fetchPromise = act(async () => { + const p = result.current.fetchBalances(OWNER); + return p; + }); + + resolveBalance(1_000_000_000); + await fetchPromise; + + expect(result.current.isLoading).toBe(false); + }); + + it('returns empty balances on complete RPC failure', async () => { + mockRpc.getBalance.mockRejectedValue(new Error('RPC down')); + mockRpc.getTokenAccountsByOwner.mockRejectedValue(new Error('RPC down')); + mockRpc.getCompressedTokenBalancesByOwnerV2.mockRejectedValue( + new Error('RPC down'), + ); + + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnwrap.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnwrap.test.ts new file mode 100644 index 0000000..5e24b6c --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useUnwrap.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockSignTransaction, + TEST_KEYPAIR, +} from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, +})); + +const dummyIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), +}); + +const mockCreateUnwrapInstructions = vi.fn(); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + createUnwrapInstructions: (...args: unknown[]) => + mockCreateUnwrapInstructions(...args), +})); + +// Keep real getAssociatedTokenAddressSync +vi.mock('@solana/spl-token', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); + +import { useUnwrap } from '../useUnwrap'; + +const signTransaction = createMockSignTransaction(); + +const TOKEN_2022_PROGRAM_ID = new PublicKey( + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +); + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getLatestBlockhash.mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }); + mockRpc.sendRawTransaction.mockResolvedValue('tx-sig-unwrap'); + signTransaction.mockImplementation(async (tx: any) => { + tx.partialSign(TEST_KEYPAIR); + return tx; + }); + // Default: mint account exists with T22 as owner + mockRpc.getAccountInfo.mockResolvedValue({ owner: TOKEN_2022_PROGRAM_ID }); + // Default: single batch with one instruction + mockCreateUnwrapInstructions.mockResolvedValue([[dummyIx]]); +}); + +describe('useUnwrap', () => { + it('builds, signs, and sends an unwrap transaction', async () => { + const { result } = renderHook(() => useUnwrap()); + + let sig: string | undefined; + await act(async () => { + sig = await result.current.unwrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 2, + decimals: 9, + }, + signTransaction, + }); + }); + + expect(sig).toBe('tx-sig-unwrap'); + expect(mockCreateUnwrapInstructions).toHaveBeenCalledOnce(); + expect(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('handles multiple instruction batches', async () => { + mockCreateUnwrapInstructions.mockResolvedValue([ + [dummyIx], + [dummyIx], + ]); + + const { result } = renderHook(() => useUnwrap()); + + await act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }); + + expect(signTransaction).toHaveBeenCalledTimes(2); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledTimes(2); + }); + + it('propagates SDK errors', async () => { + mockCreateUnwrapInstructions.mockRejectedValue( + new Error('Unwrap failed'), + ); + + const { result } = renderHook(() => useUnwrap()); + + await expect( + act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }), + ).rejects.toThrow('Unwrap failed'); + + expect(result.current.isLoading).toBe(false); + }); + + it('clears isLoading after completion', async () => { + const { result } = renderHook(() => useUnwrap()); + + await act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts new file mode 100644 index 0000000..0128977 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockSignTransaction, + TEST_KEYPAIR, +} from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, + CTOKEN_PROGRAM_ID: new PublicKey('11111111111111111111111111111111'), +})); + +const MOCK_LIGHT_ATA = PublicKey.unique(); +const dummyIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), +}); + +const mockGetSplInterfaceInfos = vi.fn(); + +vi.mock('@lightprotocol/compressed-token', () => ({ + getSplInterfaceInfos: (...args: unknown[]) => + mockGetSplInterfaceInfos(...args), +})); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_LIGHT_ATA, + createWrapInstruction: vi.fn(() => dummyIx), + createAssociatedTokenAccountInterfaceIdempotentInstruction: vi.fn(() => dummyIx), +})); + +// Partial mock: keep real getAssociatedTokenAddressSync, mock getAccount +const mockGetAccount = vi.fn(); +vi.mock('@solana/spl-token', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAccount: (...args: unknown[]) => mockGetAccount(...args) }; +}); + +import { useWrap } from '../useWrap'; + +const signTransaction = createMockSignTransaction(); +const TOKEN_2022_PROGRAM_ID = new PublicKey( + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +); + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getLatestBlockhash.mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }); + mockRpc.sendRawTransaction.mockResolvedValue('tx-sig-wrap'); + signTransaction.mockImplementation(async (tx: any) => { + tx.partialSign(TEST_KEYPAIR); + return tx; + }); + // Default: initialized SPL interface with T22 + mockGetSplInterfaceInfos.mockResolvedValue([ + { isInitialized: true, tokenProgram: TOKEN_2022_PROGRAM_ID }, + ]); + // Default: ATA exists with sufficient balance + mockGetAccount.mockResolvedValue({ amount: 10_000_000_000n }); +}); + +describe('useWrap', () => { + it('builds, signs, and sends a wrap transaction', async () => { + const { result } = renderHook(() => useWrap()); + + let sig: string | undefined; + await act(async () => { + sig = await result.current.wrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 5, + decimals: 9, + }, + signTransaction, + }); + }); + + expect(sig).toBe('tx-sig-wrap'); + expect(mockGetSplInterfaceInfos).toHaveBeenCalled(); + expect(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('throws when no SPL interface is found', async () => { + mockGetSplInterfaceInfos.mockResolvedValue([ + { isInitialized: false, tokenProgram: TOKEN_2022_PROGRAM_ID }, + ]); + + const { result } = renderHook(() => useWrap()); + + await expect( + act(async () => { + await result.current.wrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }), + ).rejects.toThrow('No SPL interface found'); + }); + + it('throws when SPL balance is insufficient', async () => { + mockGetAccount.mockResolvedValue({ amount: 100n }); + + const { result } = renderHook(() => useWrap()); + + await expect( + result.current.wrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + decimals: 9, + }, + signTransaction, + }), + ).rejects.toThrow(); + + expect(mockRpc.sendRawTransaction).not.toHaveBeenCalled(); + }); + + it('clears isLoading on error', async () => { + mockGetSplInterfaceInfos.mockRejectedValue(new Error('RPC fail')); + + const { result } = renderHook(() => useWrap()); + + try { + await act(async () => { + await result.current.wrap({ + params: { + ownerPublicKey: TEST_KEYPAIR.publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + signTransaction, + }); + }); + } catch { + // expected + } + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/index.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/index.ts new file mode 100644 index 0000000..93a4879 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/index.ts @@ -0,0 +1,6 @@ +export { useTransfer } from './useTransfer'; +export { useWrap } from './useWrap'; +export { useUnwrap } from './useUnwrap'; +export { useLightBalance } from './useLightBalance'; +export { useUnifiedBalance } from './useUnifiedBalance'; +export { useTransactionHistory } from './useTransactionHistory'; diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/signAndSendBatches.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/signAndSendBatches.ts new file mode 100644 index 0000000..4b3d2a3 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/signAndSendBatches.ts @@ -0,0 +1,34 @@ +import { Transaction, TransactionInstruction, PublicKey } from '@solana/web3.js'; + +export type SignTransactionFn = (transaction: Transaction) => Promise; + +interface SignAndSendOptions { + rpc: any; + feePayer: PublicKey; + signTransaction: SignTransactionFn; +} + +export async function signAndSendBatches( + instructionBatches: TransactionInstruction[][], + options: SignAndSendOptions, +): Promise { + const { rpc, feePayer, signTransaction } = options; + const signatures: string[] = []; + + for (const ixs of instructionBatches) { + const tx = new Transaction().add(...ixs); + const { blockhash } = await rpc.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = feePayer; + + const signedTx = await signTransaction(tx); + const sig = await rpc.sendRawTransaction(signedTx.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + await rpc.confirmTransaction(sig, 'confirmed'); + signatures.push(sig); + } + + return signatures.length > 0 ? signatures[signatures.length - 1] : null; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/useLightBalance.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/useLightBalance.ts new file mode 100644 index 0000000..f640897 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/useLightBalance.ts @@ -0,0 +1,65 @@ +import {useState, useCallback} from 'react'; +import {PublicKey} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import { + getAssociatedTokenAddressInterface, + getAtaInterface, +} from '@lightprotocol/compressed-token/unified'; + +export interface LightBalance { + /** Hot balance from Light Token associated token account. */ + hot: bigint; + /** Cold balance from compressed token accounts. */ + cold: bigint; + /** Combined hot + cold balance. */ + unified: bigint; +} + +export function useLightBalance(mintAddress: string) { + const [balance, setBalance] = useState({hot: 0n, cold: 0n, unified: 0n}); + const [isLoading, setIsLoading] = useState(false); + + const fetchBalance = useCallback( + async (ownerAddress: string) => { + if (!ownerAddress || !mintAddress) return; + + setIsLoading(true); + try { + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + const owner = new PublicKey(ownerAddress); + const mint = new PublicKey(mintAddress); + + // Hot balance from Light Token associated token account + const ata = getAssociatedTokenAddressInterface(mint, owner); + let hot = 0n; + try { + const {parsed} = await getAtaInterface(rpc, ata, owner, mint); + hot = BigInt(parsed.amount.toString()); + } catch { + // Associated token account does not exist + } + + // Cold balance from compressed token accounts + let cold = 0n; + try { + const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner, {mint}); + for (const item of compressed.value.items) { + cold += BigInt(item.balance.toString()); + } + } catch { + // No compressed token accounts + } + + setBalance({hot, cold, unified: hot + cold}); + } catch (error) { + console.error('Failed to fetch Light Token balance:', error); + setBalance({hot: 0n, cold: 0n, unified: 0n}); + } finally { + setIsLoading(false); + } + }, + [mintAddress], + ); + + return {balance, isLoading, fetchBalance}; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/useTransactionHistory.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/useTransactionHistory.ts new file mode 100644 index 0000000..1f3a447 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/useTransactionHistory.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { createRpc } from '@lightprotocol/stateless.js'; + +export interface Transaction { + signature: string; + slot: number; + blockTime: number; + timestamp: string; +} + +export function useTransactionHistory() { + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchTransactionHistory = useCallback( + async ( + ownerAddress: string, + limit: number = 10, + ) => { + if (!ownerAddress) { + setTransactions([]); + return; + } + + setIsLoading(true); + setError(null); + + try { + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + const owner = new PublicKey(ownerAddress); + + const result = await rpc.getSignaturesForOwnerInterface(owner); + + if (!result.signatures || result.signatures.length === 0) { + setTransactions([]); + return; + } + + const limitedSignatures = result.signatures.slice(0, limit); + + const basicTransactions = limitedSignatures.map((sig) => ({ + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime ?? 0, + timestamp: sig.blockTime ? new Date(sig.blockTime * 1000).toISOString() : '', + })); + + setTransactions(basicTransactions); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + setTransactions([]); + } finally { + setIsLoading(false); + } + }, + [] + ); + + return { transactions, isLoading, error, fetchTransactionHistory }; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/useTransfer.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/useTransfer.ts new file mode 100644 index 0000000..cca23d9 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/useTransfer.ts @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { + createTransferInterfaceInstructions, +} from '@lightprotocol/compressed-token/unified'; +import { createRpc } from '@lightprotocol/stateless.js'; +import { signAndSendBatches, type SignTransactionFn } from './signAndSendBatches'; + +export interface TransferParams { + ownerPublicKey: string; + mint: string; + toAddress: string; + amount: number; + decimals?: number; +} + +export interface TransferArgs { + params: TransferParams; + signTransaction: SignTransactionFn; +} + +export function useTransfer() { + const [isLoading, setIsLoading] = useState(false); + + const transfer = async (args: TransferArgs): Promise => { + setIsLoading(true); + + try { + const { params, signTransaction } = args; + const { ownerPublicKey, mint, toAddress, amount, decimals = 9 } = params; + + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + + const owner = new PublicKey(ownerPublicKey); + const mintPubkey = new PublicKey(mint); + const recipient = new PublicKey(toAddress); + const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); + + // Returns TransactionInstruction[][]. + // Each inner array is one transaction. + // Almost always returns just one. + const instructions = await createTransferInterfaceInstructions( + rpc, owner, mintPubkey, tokenAmount, owner, recipient, + ); + + const signature = await signAndSendBatches(instructions, { + rpc, + feePayer: owner, + signTransaction, + }); + + if (!signature) { + throw new Error('Transfer returned no instructions'); + } + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { transfer, isLoading }; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/useUnifiedBalance.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/useUnifiedBalance.ts new file mode 100644 index 0000000..65f6932 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/useUnifiedBalance.ts @@ -0,0 +1,159 @@ +import { useState, useCallback } from 'react'; +import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import { createRpc } from '@lightprotocol/stateless.js'; +import { + getAssociatedTokenAddressInterface, + getAtaInterface, +} from '@lightprotocol/compressed-token/unified'; + +export interface TokenBalance { + mint: string; + decimals: number; + isNative: boolean; + hot: bigint; + cold: bigint; + spl: bigint; + t22: bigint; + unified: bigint; +} + +export function useUnifiedBalance() { + const [balances, setBalances] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchBalances = useCallback(async (ownerAddress: string) => { + if (!ownerAddress) return; + + setIsLoading(true); + try { + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + const owner = new PublicKey(ownerAddress); + + // Per-mint accumulator + const mintMap = new Map(); + + const getOrCreate = (mintStr: string) => { + let entry = mintMap.get(mintStr); + if (!entry) { + entry = { spl: 0n, t22: 0n, hot: 0n, cold: 0n, decimals: 9 }; + mintMap.set(mintStr, entry); + } + return entry; + }; + + // 1. SOL balance + let solLamports = 0; + try { + solLamports = await rpc.getBalance(owner); + } catch { + // Failed to fetch SOL balance + } + + // 2. SPL accounts + try { + const splAccounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_PROGRAM_ID, + }); + for (const { account } of splAccounts.value) { + const buf = toBuffer(account.data); + if (!buf || buf.length < 72) continue; + const mint = new PublicKey(buf.subarray(0, 32)); + const amount = buf.readBigUInt64LE(64); + const mintStr = mint.toBase58(); + getOrCreate(mintStr).spl += amount; + } + } catch { + // No SPL accounts + } + + // 3. Token 2022 accounts + try { + const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_2022_PROGRAM_ID, + }); + for (const { account } of t22Accounts.value) { + const buf = toBuffer(account.data); + if (!buf || buf.length < 72) continue; + const mint = new PublicKey(buf.subarray(0, 32)); + const amount = buf.readBigUInt64LE(64); + const mintStr = mint.toBase58(); + getOrCreate(mintStr).t22 += amount; + } + } catch { + // No Token 2022 accounts + } + + // 4. Cold balance from compressed token accounts + try { + const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner); + for (const item of compressed.value.items) { + const mintStr = item.mint.toBase58(); + getOrCreate(mintStr).cold += BigInt(item.balance.toString()); + } + } catch { + // No compressed accounts + } + + // 5. Hot balance from Light Token associated token account + const mintKeys = [...mintMap.keys()]; + await Promise.allSettled( + mintKeys.map(async (mintStr) => { + try { + const mint = new PublicKey(mintStr); + const ata = getAssociatedTokenAddressInterface(mint, owner); + const { parsed } = await getAtaInterface(rpc, ata, owner, mint); + const entry = getOrCreate(mintStr); + entry.hot = BigInt(parsed.amount.toString()); + } catch { + // Associated token account does not exist for this mint — hot stays 0n + } + }), + ); + + // 6. Assemble TokenBalance[] + const result: TokenBalance[] = []; + + // SOL entry + result.push({ + mint: 'So11111111111111111111111111111111111111112', + decimals: 9, + isNative: true, + hot: 0n, + cold: 0n, + spl: BigInt(solLamports), + t22: 0n, + unified: 0n, + }); + + // Token entries + for (const [mintStr, entry] of mintMap) { + result.push({ + mint: mintStr, + decimals: entry.decimals, + isNative: false, + hot: entry.hot, + cold: entry.cold, + spl: entry.spl, + t22: entry.t22, + unified: entry.hot + entry.cold, + }); + } + + setBalances(result); + } catch (error) { + console.error('Failed to fetch balances:', error); + setBalances([]); + } finally { + setIsLoading(false); + } + }, []); + + return { balances, isLoading, fetchBalances }; +} + +function toBuffer(data: Buffer | Uint8Array | string | unknown): Buffer | null { + if (data instanceof Buffer) return data; + if (data instanceof Uint8Array) return Buffer.from(data); + return null; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/useUnwrap.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/useUnwrap.ts new file mode 100644 index 0000000..2f28c30 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/useUnwrap.ts @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync } from '@solana/spl-token'; +import { + createUnwrapInstructions, +} from '@lightprotocol/compressed-token/unified'; +import { createRpc } from '@lightprotocol/stateless.js'; +import { signAndSendBatches, type SignTransactionFn } from './signAndSendBatches'; + +export interface UnwrapParams { + ownerPublicKey: string; + mint: string; + amount: number; + decimals?: number; +} + +export interface UnwrapArgs { + params: UnwrapParams; + signTransaction: SignTransactionFn; +} + +export function useUnwrap() { + const [isLoading, setIsLoading] = useState(false); + + const unwrap = async (args: UnwrapArgs): Promise => { + setIsLoading(true); + + try { + const { params, signTransaction } = args; + const { ownerPublicKey, mint, amount, decimals = 9 } = params; + + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + + const owner = new PublicKey(ownerPublicKey); + const mintPubkey = new PublicKey(mint); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Auto-detect token program (SPL vs T22) from mint account owner + const mintAccountInfo = await rpc.getAccountInfo(mintPubkey); + if (!mintAccountInfo) throw new Error(`Mint account ${mint} not found`); + const tokenProgramId = mintAccountInfo.owner; + + // Destination: SPL/T22 associated token account + const splAta = getAssociatedTokenAddressSync(mintPubkey, owner, false, tokenProgramId); + + // Returns TransactionInstruction[][]. + // Each inner array is one transaction. + // Handles loading + unwrapping together. + const instructions = await createUnwrapInstructions( + rpc, splAta, owner, mintPubkey, tokenAmount, owner, + ); + + const signature = await signAndSendBatches(instructions, { + rpc, + feePayer: owner, + signTransaction, + }); + + if (!signature) { + throw new Error('Unwrap returned no instructions'); + } + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { unwrap, isLoading }; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts new file mode 100644 index 0000000..0ee26bc --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { PublicKey, Transaction, ComputeBudgetProgram } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync, getAccount } from '@solana/spl-token'; +import { getSplInterfaceInfos } from '@lightprotocol/compressed-token'; +import { + createWrapInstruction, + getAssociatedTokenAddressInterface, + createAssociatedTokenAccountInterfaceIdempotentInstruction, +} from '@lightprotocol/compressed-token/unified'; +import { createRpc, CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import type { SignTransactionFn } from './signAndSendBatches'; + +export interface WrapParams { + ownerPublicKey: string; + mint: string; + amount: number; + decimals?: number; +} + +export interface WrapArgs { + params: WrapParams; + signTransaction: SignTransactionFn; +} + +export function useWrap() { + const [isLoading, setIsLoading] = useState(false); + + const wrap = async (args: WrapArgs): Promise => { + setIsLoading(true); + + try { + const { params, signTransaction } = args; + const { ownerPublicKey, mint, amount, decimals = 9 } = params; + + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + + const owner = new PublicKey(ownerPublicKey); + const mintPubkey = new PublicKey(mint); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Get SPL interface info — determines whether mint uses SPL or T22 + const splInterfaceInfos = await getSplInterfaceInfos(rpc, mintPubkey); + const splInterfaceInfo = splInterfaceInfos.find( + (info) => info.isInitialized, + ); + if (!splInterfaceInfo) throw new Error('No SPL interface found for this mint'); + const { tokenProgram } = splInterfaceInfo; + + // Derive source associated token account using the mint's token program (SPL or T22) + const splAta = getAssociatedTokenAddressSync(mintPubkey, owner, false, tokenProgram); + const ataAccount = await getAccount(rpc, splAta, undefined, tokenProgram); + if (ataAccount.amount < BigInt(tokenAmount)) { + throw new Error('Insufficient SPL balance'); + } + + // Derive light-token associated token account + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, owner); + + // Build transaction + const tx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + createAssociatedTokenAccountInterfaceIdempotentInstruction( + owner, lightTokenAta, owner, mintPubkey, CTOKEN_PROGRAM_ID, + ), + createWrapInstruction( + splAta, lightTokenAta, owner, mintPubkey, + tokenAmount, splInterfaceInfo, decimals, owner, + ), + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = owner; + + const signedTx = await signTransaction(tx); + return rpc.sendRawTransaction(signedTx.serialize(), { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + } finally { + setIsLoading(false); + } + }; + + return { wrap, isLoading }; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/index.css b/toolkits/sign-with-wallet-adapter/react/src/index.css new file mode 100644 index 0000000..dffaa31 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/index.css @@ -0,0 +1,21 @@ +@import "tailwindcss"; + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.button { + @apply flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer; +} + +.button-primary { + @apply px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed; +} + +.input { + @apply w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent; +} + +.section { + @apply bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6; +} diff --git a/toolkits/sign-with-wallet-adapter/react/src/main.tsx b/toolkits/sign-with-wallet-adapter/react/src/main.tsx new file mode 100644 index 0000000..cc1f9e6 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/main.tsx @@ -0,0 +1,28 @@ +import { StrictMode, useMemo } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react'; +import { WalletModalProvider } from '@solana/wallet-adapter-react-ui'; +import App from './App'; +import '@solana/wallet-adapter-react-ui/styles.css'; +import './index.css'; + +function Root() { + const endpoint = import.meta.env.VITE_HELIUS_RPC_URL; + const wallets = useMemo(() => [], []); + + return ( + + + + + + + + ); +} + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/toolkits/sign-with-wallet-adapter/react/src/test-setup.ts b/toolkits/sign-with-wallet-adapter/react/src/test-setup.ts new file mode 100644 index 0000000..a4bdd3d --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/test-setup.ts @@ -0,0 +1,3 @@ +// NOTE: VITE_HELIUS_RPC_URL is intentionally NOT stubbed here. +// - Unit tests mock createRpc() entirely, so the URL is irrelevant. +// - Integration tests check for the real URL and skip when it's not set. diff --git a/toolkits/sign-with-wallet-adapter/react/src/vite-env.d.ts b/toolkits/sign-with-wallet-adapter/react/src/vite-env.d.ts new file mode 100644 index 0000000..5fc7488 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_HELIUS_RPC_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/toolkits/sign-with-wallet-adapter/react/tsconfig.json b/toolkits/sign-with-wallet-adapter/react/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/toolkits/sign-with-wallet-adapter/react/tsconfig.node.json b/toolkits/sign-with-wallet-adapter/react/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/toolkits/sign-with-wallet-adapter/react/vite.config.ts b/toolkits/sign-with-wallet-adapter/react/vite.config.ts new file mode 100644 index 0000000..71ba862 --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +export default defineConfig({ + plugins: [react(), tailwindcss(), nodePolyfills({ include: ['buffer'] })], + test: { + environment: 'happy-dom', + globals: true, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + exclude: ['src/**/integration/**'], + setupFiles: ['src/test-setup.ts'], + }, +}); diff --git a/toolkits/sign-with-wallet-adapter/react/vitest.integration.config.ts b/toolkits/sign-with-wallet-adapter/react/vitest.integration.config.ts new file mode 100644 index 0000000..6c319da --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/vitest.integration.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +export default defineConfig({ + plugins: [react(), nodePolyfills({ include: ['buffer'] })], + test: { + environment: 'happy-dom', + globals: true, + include: ['src/**/integration/**/*.test.ts'], + testTimeout: 30_000, + sequence: { concurrent: false }, + fileParallelism: false, + }, +}); From d4959d55dfc33deb847ae588d722436ef1bf9f9b Mon Sep 17 00:00:00 2001 From: "Klaus T." Date: Thu, 26 Feb 2026 03:15:11 +0000 Subject: [PATCH 2/4] fix: add confirmTransaction to useWrap, expand README - useWrap now awaits confirmTransaction after sendRawTransaction, matching the behavior of signAndSendBatches (used by transfer/unwrap) - Added confirmTransaction assertion to wrap test - README expanded to match Privy example quality: hook docs, component docs, setup helpers table, quick start guide Co-Authored-By: Claude Opus 4.6 --- .../sign-with-wallet-adapter/react/README.md | 116 ++++++++++++++++-- .../react/src/hooks/__tests__/useWrap.test.ts | 1 + .../react/src/hooks/useWrap.ts | 4 +- 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/toolkits/sign-with-wallet-adapter/react/README.md b/toolkits/sign-with-wallet-adapter/react/README.md index e9a9275..6c9b84c 100644 --- a/toolkits/sign-with-wallet-adapter/react/README.md +++ b/toolkits/sign-with-wallet-adapter/react/README.md @@ -1,17 +1,116 @@ -# React + Wallet Adapter — Light Token Example +# Wallet Adapter + Light Token (React) -Send, wrap, and unwrap Light Tokens using `@solana/wallet-adapter-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 +``` + +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 VITE_HELIUS_RPC_URL (devnet) +# Fill in your credentials +``` -pnpm install -pnpm dev +### 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 [amount] [decimals]` | Create an SPL or T22 mint with interface PDA, mint tokens, wrap, and transfer to recipient. | +| `npm run mint:spl [amount] [decimals]` | Mint additional SPL or T22 tokens to an existing mint. | +| `npm run register:spl-interface ` | 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 + +# 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 @@ -21,10 +120,3 @@ pnpm test # Integration tests (devnet) VITE_HELIUS_RPC_URL=https://devnet.helius-rpc.com?api-key=... pnpm test:integration ``` - -## Stack - -- React 19, Vite, Tailwind v4 -- `@solana/wallet-adapter-react` + `@solana/wallet-adapter-react-ui` -- `@lightprotocol/compressed-token` (unified interface) -- `@solana/web3.js` 1.x diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts index 0128977..4060c29 100644 --- a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/useWrap.test.ts @@ -88,6 +88,7 @@ describe('useWrap', () => { expect(mockGetSplInterfaceInfos).toHaveBeenCalled(); expect(signTransaction).toHaveBeenCalledOnce(); expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.confirmTransaction).toHaveBeenCalledWith('tx-sig-wrap', 'confirmed'); }); it('throws when no SPL interface is found', async () => { diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts index 0ee26bc..558e34c 100644 --- a/toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/useWrap.ts @@ -73,10 +73,12 @@ export function useWrap() { tx.feePayer = owner; const signedTx = await signTransaction(tx); - return rpc.sendRawTransaction(signedTx.serialize(), { + const sig = await rpc.sendRawTransaction(signedTx.serialize(), { skipPreflight: false, preflightCommitment: 'confirmed', }); + await rpc.confirmTransaction(sig, 'confirmed'); + return sig; } finally { setIsLoading(false); } From 01fe00d741011f16053a0274d8ef8a0386009446 Mon Sep 17 00:00:00 2001 From: "Klaus T." Date: Thu, 26 Feb 2026 03:32:22 +0000 Subject: [PATCH 3/4] test: add e2e integration test for wallet-adapter hooks Tests useUnifiedBalance, useTransfer, and useTransactionHistory against devnet using filesystem keypair signing. Creates a fresh mint with SPL interface, mints tokens, wraps to light-token, then exercises the full transfer + balance + history flow. Run: VITE_HELIUS_RPC_URL= pnpm test:integration Co-Authored-By: Claude Opus 4.6 --- .../e2e-signing.integration.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts new file mode 100644 index 0000000..0adaacd --- /dev/null +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts @@ -0,0 +1,173 @@ +/** + * E2E integration test for signing hooks. + * + * Uses the filesystem keypair (~/.config/solana/id.json) to sign transactions. + * Requires VITE_HELIUS_RPC_URL to be set. + * + * Run: VITE_HELIUS_RPC_URL= pnpm test:integration + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; +import { readFileSync } from 'fs'; +import { homedir } from 'os'; +import { createRpc } from '@lightprotocol/stateless.js'; +import { + createMintInterface, + createAtaInterfaceIdempotent, + getAssociatedTokenAddressInterface, + wrap, +} from '@lightprotocol/compressed-token'; +import { + TOKEN_2022_PROGRAM_ID, + createAssociatedTokenAccount, + mintTo, +} from '@solana/spl-token'; +import { useTransfer } from '../../useTransfer'; +import { useUnifiedBalance } from '../../useUnifiedBalance'; +import { useTransactionHistory } from '../../useTransactionHistory'; +import type { SignTransactionFn } from '../../signAndSendBatches'; + +const RPC_URL = import.meta.env.VITE_HELIUS_RPC_URL; + +// Load filesystem keypair for signing +function loadKeypair(): Keypair { + const raw = readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8'); + return Keypair.fromSecretKey(new Uint8Array(JSON.parse(raw))); +} + +// Create a signTransaction function from a Keypair (same interface as wallet-adapter) +function createKeypairSigner(keypair: Keypair): SignTransactionFn { + return async (tx: Transaction): Promise => { + tx.partialSign(keypair); + return tx; + }; +} + +describe.runIf(RPC_URL)('e2e signing (devnet)', () => { + let payer: Keypair; + let signTransaction: SignTransactionFn; + let rpc: ReturnType; + let testMint: PublicKey; + + beforeAll(async () => { + payer = loadKeypair(); + signTransaction = createKeypairSigner(payer); + rpc = createRpc(RPC_URL); + + console.log('Payer:', payer.publicKey.toBase58()); + + // Check SOL balance + const balance = await rpc.getBalance(payer.publicKey); + console.log('SOL balance:', balance / 1e9); + expect(balance).toBeGreaterThan(0.1e9); // Need at least 0.1 SOL + + // Create a test mint with SPL interface + mint + wrap tokens to payer + const mintKeypair = Keypair.generate(); + const decimals = 9; + const amount = 10; // 10 tokens + + console.log('Creating test mint...'); + const { mint } = await createMintInterface( + rpc, + payer, + payer, + null, + decimals, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + testMint = mint; + console.log('Test mint:', testMint.toBase58()); + + // Mint SPL tokens to payer + const tokenAmount = BigInt(amount * 10 ** decimals); + const payerSplAta = await createAssociatedTokenAccount( + rpc, + payer, + testMint, + payer.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + await mintTo( + rpc, + payer, + testMint, + payerSplAta, + payer, + tokenAmount, + [], + undefined, + TOKEN_2022_PROGRAM_ID, + ); + console.log('Minted', amount, 'tokens'); + + // Wrap into light-token + await createAtaInterfaceIdempotent(rpc, payer, testMint, payer.publicKey); + const payerLightAta = getAssociatedTokenAddressInterface(testMint, payer.publicKey); + await wrap(rpc, payer, payerSplAta, payerLightAta, payer, testMint, tokenAmount); + console.log('Wrapped into light-token ATA'); + }, 120_000); + + it('useUnifiedBalance returns light-token balance for funded wallet', async () => { + const { result } = renderHook(() => useUnifiedBalance()); + + await act(async () => { + await result.current.fetchBalances(payer.publicKey.toBase58()); + }); + + expect(result.current.isLoading).toBe(false); + + // Should have SOL + const sol = result.current.balances.find(b => b.isNative); + expect(sol).toBeDefined(); + expect(sol!.spl).toBeGreaterThan(0n); + + // Should have our test mint with hot balance + const token = result.current.balances.find(b => b.mint === testMint.toBase58()); + expect(token).toBeDefined(); + expect(token!.hot).toBeGreaterThan(0n); + expect(token!.unified).toBeGreaterThan(0n); + console.log('Balance check passed. Hot:', token!.hot.toString(), 'Unified:', token!.unified.toString()); + }, 30_000); + + it('useTransfer sends light-tokens to a fresh address', async () => { + const recipient = Keypair.generate(); + const { result } = renderHook(() => useTransfer()); + + let signature: string | undefined; + await act(async () => { + signature = await result.current.transfer({ + params: { + ownerPublicKey: payer.publicKey.toBase58(), + mint: testMint.toBase58(), + toAddress: recipient.publicKey.toBase58(), + amount: 1, + decimals: 9, + }, + signTransaction, + }); + }); + + expect(signature).toBeDefined(); + expect(typeof signature).toBe('string'); + expect(signature!.length).toBeGreaterThan(40); + console.log('Transfer signature:', signature); + }, 60_000); + + it('useTransactionHistory returns recent transactions', async () => { + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(payer.publicKey.toBase58(), 5); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + // Should have at least the transfer we just did + expect(result.current.transactions.length).toBeGreaterThan(0); + console.log('Transaction history:', result.current.transactions.length, 'entries'); + }, 30_000); +}); From d768f668125f1fd0602e14728dfec6b2ed854d98 Mon Sep 17 00:00:00 2001 From: "Klaus T." Date: Thu, 26 Feb 2026 03:52:48 +0000 Subject: [PATCH 4/4] test: add localnet mode to e2e integration test Support both devnet and localnet testing: - VITE_HELIUS_RPC_URL= for devnet (Helius bundles compression API) - VITE_LOCALNET=true for localnet (createRpc() uses default ports 8899/8784/3001) - Localnet mode airdrops SOL before running tests Note: localnet currently blocked by CLI/SDK version mismatch (CLI v0.27.0 programs don't match SDK v0.23.0-beta.9 instructions). Devnet tests pass. Co-Authored-By: Claude Opus 4.6 --- .../e2e-signing.integration.test.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts index 0adaacd..a8d168b 100644 --- a/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts +++ b/toolkits/sign-with-wallet-adapter/react/src/hooks/__tests__/integration/e2e-signing.integration.test.ts @@ -2,9 +2,11 @@ * E2E integration test for signing hooks. * * Uses the filesystem keypair (~/.config/solana/id.json) to sign transactions. - * Requires VITE_HELIUS_RPC_URL to be set. * - * Run: VITE_HELIUS_RPC_URL= pnpm test:integration + * Modes: + * Devnet: VITE_HELIUS_RPC_URL= pnpm test:integration + * Localnet: VITE_LOCALNET=true pnpm test:integration + * (requires `light test-validator` running on ports 8899/8784/3001) */ import { describe, it, expect, beforeAll } from 'vitest'; import { renderHook, act } from '@testing-library/react'; @@ -29,6 +31,8 @@ import { useTransactionHistory } from '../../useTransactionHistory'; import type { SignTransactionFn } from '../../signAndSendBatches'; const RPC_URL = import.meta.env.VITE_HELIUS_RPC_URL; +const IS_LOCALNET = import.meta.env.VITE_LOCALNET === 'true'; +const ENABLED = !!RPC_URL || IS_LOCALNET; // Load filesystem keypair for signing function loadKeypair(): Keypair { @@ -44,7 +48,7 @@ function createKeypairSigner(keypair: Keypair): SignTransactionFn { }; } -describe.runIf(RPC_URL)('e2e signing (devnet)', () => { +describe.runIf(ENABLED)(`e2e signing (${IS_LOCALNET ? 'localnet' : 'devnet'})`, () => { let payer: Keypair; let signTransaction: SignTransactionFn; let rpc: ReturnType; @@ -53,11 +57,21 @@ describe.runIf(RPC_URL)('e2e signing (devnet)', () => { beforeAll(async () => { payer = loadKeypair(); signTransaction = createKeypairSigner(payer); - rpc = createRpc(RPC_URL); + + // Localnet: createRpc() with no args → uses correct localhost defaults + // (RPC 8899, Photon 8784, Prover 3001). + // Devnet: createRpc(url) → Helius bundles all services on one URL. + rpc = IS_LOCALNET ? createRpc() : createRpc(RPC_URL); console.log('Payer:', payer.publicKey.toBase58()); - // Check SOL balance + // On localnet, airdrop SOL; on devnet, just check existing balance + if (IS_LOCALNET) { + const sig = await rpc.requestAirdrop(payer.publicKey, 2e9); + await rpc.confirmTransaction(sig, 'confirmed'); + console.log('Airdropped 2 SOL'); + } + const balance = await rpc.getBalance(payer.publicKey); console.log('SOL balance:', balance / 1e9); expect(balance).toBeGreaterThan(0.1e9); // Need at least 0.1 SOL