diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eab9505 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,208 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +Light Token examples repository demonstrating rent-free token operations on Solana. Light Token reduces mint and token account costs by 200x through sponsored rent-exemption. + +## Build and test commands + +### TypeScript client + +```bash +# Install dependencies (from repo root — npm workspaces) +npm install + +# Run from typescript-client directory +cd typescript-client +npm run test:actions # Run all action examples (chained sequentially) +npm run test:instructions # Run all instruction examples (chained sequentially) + +# Individual examples (pattern: :) +npm run create-mint:action # Create light-token mint +npm run create-mint:instruction # Create mint with manual instruction building +npm run transfer-interface:action # Transfer between account types +npm run wrap:action # Wrap SPL/T22 to light-token +npm run unwrap:action # Unwrap light-token to SPL/T22 +npm run load-ata:action # Load compressed tokens into light ATA +npm run delegate:approve # Approve delegate +npm run delegate:revoke # Revoke delegate + +# All examples require a running validator — see "Local development" below +``` + +### Rust client + +```bash +cd rust-client +cargo run --example action_create_mint +cargo run --example action_transfer_interface +cargo run --example instruction_transfer_interface +``` + +### Anchor programs + +```bash +cd programs/anchor + +anchor build + +# Test single program (recommended — must use single thread) +cargo test-sbf -p -- --test-threads=1 + +# Package names: light-token-anchor-transfer-interface, light-token-anchor-mint-to, +# counter, create-and-transfer +``` + +### Privy Node.js (devnet) + +```bash +cd privy/nodejs +npm install +cp .env.example .env # fill in Privy credentials + Helius RPC URL + +# App operations (Privy-signed server wallet) +npm run transfer # Light-token ATA → ATA transfer (auto-loads cold balance) +npm run wrap # SPL/T22 → light-token ATA +npm run unwrap # Light-token ATA → SPL/T22 +npm run load # Consolidate cold + SPL + T22 into light-token ATA +npm run balances # Query balance breakdown (hot, cold, SPL/T22, SOL) +npm run history # Transaction history for light-token interface ops + +# Setup helpers live in privy/scripts/ (separate workspace, uses local keypair) +cd ../scripts +npm run mint:spl-and-wrap # Create mint + interface PDA + fund treasury +npm run mint:spl # Mint SPL/T22 tokens to existing mint +npm run register:spl-interface # Register interface PDA on existing mint +``` + +### Privy React (devnet — WIP) + +```bash +cd privy/react +npm install +cp .env.example .env # fill in VITE_PRIVY_APP_ID and VITE_HELIUS_RPC_URL + +npm run dev # Vite dev server +npm run build # Production build +``` + +### Local development + +```bash +# Start test validator with Light programs (requires light CLI) +light test-validator + +# Validator health check +curl http://127.0.0.1:8784/health +``` + +## Architecture + +### Workspace layout + +Root `package.json` defines npm workspaces: `typescript-client`, `toolkits/payments-and-wallets`, `privy/nodejs`, `privy/react`, and `privy/scripts`. Dependencies are hoisted to root. + +- `typescript-client/` — Core examples: `actions/` (high-level) and `instructions/` (low-level) +- `rust-client/` — Rust client examples as cargo examples +- `programs/anchor/` — On-chain Anchor programs + - `basic-macros/` — Declarative `#[light_account]` macro pattern + - `basic-instructions/` — Explicit CPI calls to light-token program + - `create-and-transfer/` — Combined macro + CPI example +- `toolkits/` — Domain-specific implementations + - `payments-and-wallets/` — Wallet integration patterns (uses `@lightprotocol/compressed-token/unified` subpath) + - `streaming-tokens/` — Laserstream-based token indexing +- `privy/` — Privy wallet integration examples (devnet) + - `nodejs/` — Server-side scripts using `@privy-io/node` with server wallet signing + - `react/` — Browser app using `@privy-io/react-auth` with embedded wallet signing (WIP) + +### TypeScript: actions vs instructions + +Every example exists at two abstraction levels: + +**Actions** (`typescript-client/actions/`): Call high-level SDK functions that build, sign, and send transactions in one call. Functions like `createMintInterface`, `transferInterface`, `wrap` return a transaction signature directly. + +**Instructions** (`typescript-client/instructions/`): Build `TransactionInstruction` objects manually, assemble into `Transaction`, and call `sendAndConfirmTransaction`. Requires fetching tree info and validity proofs yourself: +- `getBatchAddressTreeInfo()` — address tree for new account creation +- `selectStateTreeInfo(await rpc.getStateTreeInfos())` — state tree selection +- `rpc.getValidityProofV2([], [{address, treeInfo}])` — ZK validity proof +- `ComputeBudgetProgram.setComputeUnitLimit({ units: 1_000_000 })` — required CU budget + +### Boilerplate pattern + +Every TypeScript example follows the same setup: + +```typescript +import "dotenv/config"; +import { createRpc } from "@lightprotocol/stateless.js"; + +// localnet (default — no args): +const rpc = createRpc(); +// devnet: +// const rpc = createRpc(`https://devnet.helius-rpc.com?api-key=${process.env.API_KEY!}`); + +// Payer from local Solana keypair: +const payer = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, "utf8"))) +); +``` + +### Token flow concepts + +- **Light-token ATA**: Associated token account derived via `getAssociatedTokenAddressInterface(mint, owner)`. Created with `createAtaInterface` or `createAtaInterfaceIdempotent`. +- **Wrap**: SPL/T22 tokens → light-token ATA. Requires SPL interface PDA registered via `createSplInterface`. +- **Unwrap**: Light-token ATA → SPL/T22 tokens. +- **Load ATA**: Compressed tokens (cold storage) → light-token ATA (hot balance). Uses `loadAta` action or `createLoadAtaInstructions` + `buildAndSignTx`/`sendAndConfirmTx`. +- **Decompress**: Light-token ATA → SPL T22 account. Used as setup step before wrapping. + +### Two on-chain patterns (Anchor programs) + +**Macro pattern** (`basic-macros/`): Declarative `#[light_account(init)]` attribute and `LightAccounts` derive macro. + +**CPI pattern** (`basic-instructions/`): Explicit CPI calls like `TransferInterfaceCpi`. + +### Privy Node.js architecture (`privy/nodejs/`) + +Server-side scripts that sign transactions via Privy's wallet API instead of a local keypair. Each script in `src/` is standalone and runnable with `tsx`. All scripts target **devnet**. + +**Script pattern**: Each file exports a reusable async function AND has a `// --- main ---` block at the bottom that imports config values and runs it. Scripts are both importable as modules and directly runnable via `npm run + + diff --git a/privy/react/package.json b/privy/react/package.json new file mode 100644 index 0000000..52dc965 --- /dev/null +++ b/privy/react/package.json @@ -0,0 +1,39 @@ +{ + "name": "react-privy-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", + "@privy-io/react-auth": "^3.9.1", + "@solana-program/memo": "^0.10.0", + "@solana/kit": "^5.5.1", + "@solana/spl-token": "^0.4.13", + "@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/privy/react/src/App.tsx b/privy/react/src/App.tsx new file mode 100644 index 0000000..ab9dd47 --- /dev/null +++ b/privy/react/src/App.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { usePrivy } from '@privy-io/react-auth'; +import { useWallets } from '@privy-io/react-auth/solana'; +import { useLightTokenBalances } from './hooks/useLightTokenBalances'; +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'; +import { ArrowLeftIcon, ClipboardIcon } from '@heroicons/react/24/outline'; + +export default function App() { + const { login, logout, authenticated, user } = usePrivy(); + const { wallets } = useWallets(); + const { balances, isLoading: isLoadingBalances, fetchBalances } = useLightTokenBalances(); + + const [selectedWallet, setSelectedWallet] = useState(''); + const [selectedMint, setSelectedMint] = useState(''); + const [txSignature, setTxSignature] = useState(null); + const [txError, setTxError] = useState(null); + + useEffect(() => { + if (wallets.length > 0 && !selectedWallet) { + setSelectedWallet(wallets[0].address); + } + }, [wallets, selectedWallet]); + + useEffect(() => { + if (!selectedWallet) { + setSelectedMint(''); + return; + } + + const loadBalances = async () => { + await fetchBalances(selectedWallet); + }; + + loadBalances(); + }, [selectedWallet, fetchBalances]); + + useEffect(() => { + if (balances.length > 0 && !selectedMint) { + setSelectedMint(balances[0].mint); + } + }, [balances, selectedMint]); + + const handleWalletChange = (address: string) => { + setSelectedWallet(address); + setSelectedMint(''); + }; + + const handleTransferSuccess = async (signature: string) => { + setTxSignature(signature); + setTxError(null); + await fetchBalances(selectedWallet); + }; + + const handleTransferError = (error: string) => { + setTxError(error); + setTxSignature(null); + }; + + const copyAddress = () => { + if (user?.wallet?.address) { + navigator.clipboard.writeText(user.wallet.address); + alert('Address copied!'); + } + }; + + return ( +
+
+ {authenticated ? ( +
+
+ + {user?.wallet?.address && ( + + )} +
+ +
+
+ + + + + + + +
+
+
+ ) : ( +
+
+

+ Send Tokens +

+

+ Send light tokens to any Solana address instantly. +

+ +
+
+ )} +
+ ); +} diff --git a/privy/react/src/components/reusables/CopyButton.tsx b/privy/react/src/components/reusables/CopyButton.tsx new file mode 100644 index 0000000..71258b3 --- /dev/null +++ b/privy/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/privy/react/src/components/reusables/Section.tsx b/privy/react/src/components/reusables/Section.tsx new file mode 100644 index 0000000..2726631 --- /dev/null +++ b/privy/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/privy/react/src/components/sections/TransactionHistory.tsx b/privy/react/src/components/sections/TransactionHistory.tsx new file mode 100644 index 0000000..1abeff3 --- /dev/null +++ b/privy/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/privy/react/src/components/sections/TransactionStatus.tsx b/privy/react/src/components/sections/TransactionStatus.tsx new file mode 100644 index 0000000..709cdc2 --- /dev/null +++ b/privy/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/privy/react/src/components/sections/TransferForm.tsx b/privy/react/src/components/sections/TransferForm.tsx new file mode 100644 index 0000000..ce31249 --- /dev/null +++ b/privy/react/src/components/sections/TransferForm.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; +import { useTransfer } from '../../hooks/useTransfer'; +import type { TokenBalance } from '../../hooks/useLightTokenBalances'; +import CopyButton from '../reusables/CopyButton'; +import Section from '../reusables/Section'; + +interface TransferFormProps { + selectedWallet: string; + wallets: ConnectedStandardSolanaWallet[]; + onWalletChange: (address: string) => void; + 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({ + selectedWallet, + wallets, + onWalletChange, + selectedMint, + onMintChange, + balances, + isLoadingBalances, + onTransferSuccess, + onTransferError, +}: TransferFormProps) { + const { signTransaction } = useSignTransaction(); + const { transfer } = useTransfer(); + + const [recipientAddress, setRecipientAddress] = useState(''); + const [amount, setAmount] = useState('1'); + const [isLoading, setIsLoading] = useState(false); + + const handleTransfer = async () => { + if (!selectedWallet || !selectedMint || !recipientAddress) return; + + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum <= 0) { + alert('Please enter a valid amount'); + return; + } + + const wallet = wallets.find((w) => w.address === selectedWallet); + if (!wallet) return; + + const selectedToken = balances.find(b => b.mint === selectedMint); + if (!selectedToken || selectedToken.unified === 0n) return; + + setIsLoading(true); + + try { + const signature = await transfer({ + params: { + ownerPublicKey: selectedWallet, + mint: selectedMint, + toAddress: recipientAddress, + amount: amountNum, + decimals: selectedToken.decimals, + }, + wallet, + 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 ( +
+
+ +
+ + {selectedWallet && ( + + )} +
+
+ +
+ +
+ +
+
+ +
+ + 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/privy/react/src/components/sections/WalletInfo.tsx b/privy/react/src/components/sections/WalletInfo.tsx new file mode 100644 index 0000000..89e1587 --- /dev/null +++ b/privy/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/privy/react/src/components/ui/Header.tsx b/privy/react/src/components/ui/Header.tsx new file mode 100644 index 0000000..0ea9234 --- /dev/null +++ b/privy/react/src/components/ui/Header.tsx @@ -0,0 +1,30 @@ +export function Header() { + return ( +
+
+
+ Light Token + + Privy +
+ +
+
+ ); +} diff --git a/privy/react/src/hooks/__tests__/integration/hooks.integration.test.ts b/privy/react/src/hooks/__tests__/integration/hooks.integration.test.ts new file mode 100644 index 0000000..ae632e1 --- /dev/null +++ b/privy/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 { useLightTokenBalances } from '../../useLightTokenBalances'; +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('useLightTokenBalances', () => { + it('fetches SOL balance for empty wallet', async () => { + const { result } = renderHook(() => useLightTokenBalances()); + + 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/privy/react/src/hooks/__tests__/mock-rpc.ts b/privy/react/src/hooks/__tests__/mock-rpc.ts new file mode 100644 index 0000000..302015a --- /dev/null +++ b/privy/react/src/hooks/__tests__/mock-rpc.ts @@ -0,0 +1,48 @@ +import { vi } from 'vitest'; +import { PublicKey } from '@solana/web3.js'; + +/** 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 Privy wallet. */ +export function createMockWallet() { + return { + address: '11111111111111111111111111111112', + walletClientType: 'privy', + connectorType: 'embedded', + } as any; +} + +/** Creates a mock signTransaction function. */ +export function createMockSignTransaction() { + return vi.fn().mockResolvedValue({ + signedTransaction: Buffer.alloc(64), // dummy signed bytes + }); +} diff --git a/privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts b/privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts new file mode 100644 index 0000000..0156a9b --- /dev/null +++ b/privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts @@ -0,0 +1,235 @@ +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, +})); + +// Deterministic light-token ATA address for mock comparison +const MOCK_LIGHT_ATA = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); + +const mockGetAtaInterface = vi.fn(); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_LIGHT_ATA, + getAtaInterface: (...args: unknown[]) => mockGetAtaInterface(...args), +})); + +// Import after mocks are hoisted +import { useLightTokenBalances } from '../useLightTokenBalances'; + +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 ATA exists) + mockGetAtaInterface.mockRejectedValue(new Error('Account not found')); +}); + +describe('useLightTokenBalances', () => { + it('returns empty balances and does not fetch when address is empty', async () => { + const { result } = renderHook(() => useLightTokenBalances()); + + 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(() => useLightTokenBalances()); + + 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: T22 accounts + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ + value: [{ pubkey: PublicKey.unique(), account: { data } }], + }) + .mockResolvedValueOnce({ value: [] }); + + const { result } = renderHook(() => useLightTokenBalances()); + + 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('excludes light-token ATA from T22 balance', async () => { + const mint = PublicKey.unique(); + const data = buildTokenAccountData(mint, 1_000_000n); + + // SPL: empty, T22: one account whose pubkey matches the mock light ATA + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ + value: [{ pubkey: MOCK_LIGHT_ATA, account: { data } }], + }); + + // getAtaInterface returns the hot balance for this mint + mockGetAtaInterface.mockResolvedValue({ + parsed: { amount: 1_000_000n }, + }); + + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const entry = result.current.balances.find( + (b) => b.mint === mint.toBase58(), + ); + expect(entry).toBeDefined(); + // T22 balance should be 0 (light-token ATA excluded) + expect(entry!.t22).toBe(0n); + // Hot balance should come from getAtaInterface + expect(entry!.hot).toBe(1_000_000n); + expect(entry!.unified).toBe(1_000_000n); + }); + + it('aggregates hot and cold balances into unified', async () => { + const mint = PublicKey.unique(); + const hotData = buildTokenAccountData(mint, 300_000n); + + // T22 account is a light-token ATA + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ + value: [{ pubkey: MOCK_LIGHT_ATA, account: { data: hotData } }], + }); + + // 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(() => useLightTokenBalances()); + + 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); + // unified = hot + cold + 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(() => useLightTokenBalances()); + + 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(() => useLightTokenBalances()); + + 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(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/privy/react/src/hooks/__tests__/useTransactionHistory.test.ts b/privy/react/src/hooks/__tests__/useTransactionHistory.test.ts new file mode 100644 index 0000000..517018a --- /dev/null +++ b/privy/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/privy/react/src/hooks/__tests__/useTransfer.test.ts b/privy/react/src/hooks/__tests__/useTransfer.test.ts new file mode 100644 index 0000000..2c250ef --- /dev/null +++ b/privy/react/src/hooks/__tests__/useTransfer.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockWallet, + createMockSignTransaction, +} 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), +})); + +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +import { useTransfer } from '../useTransfer'; + +const wallet = createMockWallet(); +const signTransaction = createMockSignTransaction(); + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getLatestBlockhash.mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }); + mockRpc.sendRawTransaction.mockResolvedValue('tx-sig-transfer'); + signTransaction.mockResolvedValue({ + signedTransaction: Buffer.alloc(64), + }); + // 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1.5, + decimals: 9, + }, + wallet, + 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }), + ).rejects.toThrow('Transaction failed'); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/privy/react/src/hooks/__tests__/useUnwrap.test.ts b/privy/react/src/hooks/__tests__/useUnwrap.test.ts new file mode 100644 index 0000000..95cdff0 --- /dev/null +++ b/privy/react/src/hooks/__tests__/useUnwrap.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockWallet, + createMockSignTransaction, +} 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), +})); + +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +// Keep real getAssociatedTokenAddressSync +vi.mock('@solana/spl-token', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); + +import { useUnwrap } from '../useUnwrap'; + +const wallet = createMockWallet(); +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.mockResolvedValue({ + signedTransaction: Buffer.alloc(64), + }); + // 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: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 2, + decimals: 9, + }, + wallet, + 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: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + 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: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + 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: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/privy/react/src/hooks/__tests__/useWrap.test.ts b/privy/react/src/hooks/__tests__/useWrap.test.ts new file mode 100644 index 0000000..3619ab9 --- /dev/null +++ b/privy/react/src/hooks/__tests__/useWrap.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockWallet, + createMockSignTransaction, +} 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), +})); + +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +// 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 wallet = createMockWallet(); +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.mockResolvedValue({ + signedTransaction: Buffer.alloc(64), + }); + // 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 5, + decimals: 9, + }, + wallet, + 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + decimals: 9, + }, + wallet, + 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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + } catch { + // expected + } + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/privy/react/src/hooks/index.ts b/privy/react/src/hooks/index.ts new file mode 100644 index 0000000..038828e --- /dev/null +++ b/privy/react/src/hooks/index.ts @@ -0,0 +1,5 @@ +export { useTransfer } from './useTransfer'; +export { useWrap } from './useWrap'; +export { useUnwrap } from './useUnwrap'; +export { useLightTokenBalances } from './useLightTokenBalances'; +export { useTransactionHistory } from './useTransactionHistory'; diff --git a/privy/react/src/hooks/signAndSendBatches.ts b/privy/react/src/hooks/signAndSendBatches.ts new file mode 100644 index 0000000..99b6517 --- /dev/null +++ b/privy/react/src/hooks/signAndSendBatches.ts @@ -0,0 +1,44 @@ +import { Transaction, TransactionInstruction, PublicKey } from '@solana/web3.js'; +import type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; + +type SignTransactionFn = ReturnType['signTransaction']; + +interface SignAndSendOptions { + rpc: any; + feePayer: PublicKey; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export async function signAndSendBatches( + instructionBatches: TransactionInstruction[][], + options: SignAndSendOptions, +): Promise { + const { rpc, feePayer, wallet, 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 unsignedTxBuffer = tx.serialize({ requireAllSignatures: false }); + const signedTx = await signTransaction({ + transaction: unsignedTxBuffer, + wallet, + chain: 'solana:devnet', + }); + + const signedTxBuffer = Buffer.from(signedTx.signedTransaction); + const sig = await rpc.sendRawTransaction(signedTxBuffer, { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + await rpc.confirmTransaction(sig, 'confirmed'); + signatures.push(sig); + } + + return signatures.length > 0 ? signatures[signatures.length - 1] : null; +} diff --git a/privy/react/src/hooks/useLightTokenBalances.ts b/privy/react/src/hooks/useLightTokenBalances.ts new file mode 100644 index 0000000..4125607 --- /dev/null +++ b/privy/react/src/hooks/useLightTokenBalances.ts @@ -0,0 +1,169 @@ +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 useLightTokenBalances() { + 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; + }; + + // Track which mints we found via T22 light-token ATAs (skip in t22 accumulation) + const lightAtaMints = new Set(); + + // 1. SOL balance + let solLamports = 0; + try { + solLamports = await rpc.getBalance(owner); + } catch { + // Failed to fetch SOL balance + } + + // 2. SPL accounts (standard token program) + 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. T22 accounts (Token-2022) + try { + const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_2022_PROGRAM_ID, + }); + for (const { pubkey, account } of t22Accounts.value) { + const buf = toBuffer(account.data); + if (!buf || buf.length < 72) continue; + const mint = new PublicKey(buf.subarray(0, 32)); + const mintStr = mint.toBase58(); + const expectedAta = getAssociatedTokenAddressInterface(mint, owner); + if (pubkey.equals(expectedAta)) { + // This is a light-token ATA — will query via getAtaInterface instead + lightAtaMints.add(mintStr); + getOrCreate(mintStr); // ensure entry exists for hot balance query + } else { + const amount = buf.readBigUInt64LE(64); + getOrCreate(mintStr).t22 += amount; + } + } + } catch { + // No T22 accounts + } + + // 4. Hot balance via getAtaInterface (parallelize per mint) + 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 { + // ATA does not exist for this mint — hot stays 0n + } + }), + ); + + // 5. Cold balance (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 + } + + // 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/privy/react/src/hooks/useTransactionHistory.ts b/privy/react/src/hooks/useTransactionHistory.ts new file mode 100644 index 0000000..1f3a447 --- /dev/null +++ b/privy/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/privy/react/src/hooks/useTransfer.ts b/privy/react/src/hooks/useTransfer.ts new file mode 100644 index 0000000..199a6fd --- /dev/null +++ b/privy/react/src/hooks/useTransfer.ts @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { + createTransferInterfaceInstructions, +} from '@lightprotocol/compressed-token/unified'; +import { createRpc } from '@lightprotocol/stateless.js'; +import type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; +import { signAndSendBatches } from './signAndSendBatches'; + +type SignTransactionFn = ReturnType['signTransaction']; + +export interface TransferParams { + ownerPublicKey: string; + mint: string; + toAddress: string; + amount: number; + decimals?: number; +} + +export interface TransferArgs { + params: TransferParams; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export function useTransfer() { + const [isLoading, setIsLoading] = useState(false); + + const transfer = async (args: TransferArgs): Promise => { + setIsLoading(true); + + try { + const { params, wallet, 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, + wallet, + signTransaction, + }); + + if (!signature) { + throw new Error('Transfer returned no instructions'); + } + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { transfer, isLoading }; +} diff --git a/privy/react/src/hooks/useUnwrap.ts b/privy/react/src/hooks/useUnwrap.ts new file mode 100644 index 0000000..7cbe8bf --- /dev/null +++ b/privy/react/src/hooks/useUnwrap.ts @@ -0,0 +1,76 @@ +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 type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; +import { signAndSendBatches } from './signAndSendBatches'; + +type SignTransactionFn = ReturnType['signTransaction']; + +export interface UnwrapParams { + ownerPublicKey: string; + mint: string; + amount: number; + decimals?: number; +} + +export interface UnwrapArgs { + params: UnwrapParams; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export function useUnwrap() { + const [isLoading, setIsLoading] = useState(false); + + const unwrap = async (args: UnwrapArgs): Promise => { + setIsLoading(true); + + try { + const { params, wallet, 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, + wallet, + signTransaction, + }); + + if (!signature) { + throw new Error('Unwrap returned no instructions'); + } + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { unwrap, isLoading }; +} diff --git a/privy/react/src/hooks/useWrap.ts b/privy/react/src/hooks/useWrap.ts new file mode 100644 index 0000000..34b95b3 --- /dev/null +++ b/privy/react/src/hooks/useWrap.ts @@ -0,0 +1,97 @@ +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 { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; + +type SignTransactionFn = ReturnType['signTransaction']; + +export interface WrapParams { + ownerPublicKey: string; + mint: string; + amount: number; + decimals?: number; +} + +export interface WrapArgs { + params: WrapParams; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export function useWrap() { + const [isLoading, setIsLoading] = useState(false); + + const wrap = async (args: WrapArgs): Promise => { + setIsLoading(true); + + try { + const { params, wallet, 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 unsignedTxBuffer = tx.serialize({ requireAllSignatures: false }); + const signedTx = await signTransaction({ + transaction: unsignedTxBuffer, + wallet, + chain: 'solana:devnet', + }); + + const signedTxBuffer = Buffer.from(signedTx.signedTransaction); + return rpc.sendRawTransaction(signedTxBuffer, { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + } finally { + setIsLoading(false); + } + }; + + return { wrap, isLoading }; +} diff --git a/privy/react/src/index.css b/privy/react/src/index.css new file mode 100644 index 0000000..f9e38ba --- /dev/null +++ b/privy/react/src/index.css @@ -0,0 +1,33 @@ +@import "tailwindcss"; + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* Ensure Privy modal displays above everything */ +#privy-modal-content, +[data-privy-dialog], +[data-radix-portal] { + z-index: 9999 !important; +} + +/* Fix for Privy iframe */ +iframe[src*="privy"] { + z-index: 9999 !important; +} + +.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/privy/react/src/main.tsx b/privy/react/src/main.tsx new file mode 100644 index 0000000..000db4e --- /dev/null +++ b/privy/react/src/main.tsx @@ -0,0 +1,48 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { PrivyProvider } from '@privy-io/react-auth'; +import { toSolanaWalletConnectors } from '@privy-io/react-auth/solana'; +import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit'; +import App from './App'; +import './index.css'; + +const solanaConnectors = toSolanaWalletConnectors({ + shouldAutoConnect: true, +}); + +const heliusRpcUrl = import.meta.env.VITE_HELIUS_RPC_URL; +const heliusWsUrl = heliusRpcUrl.replace('https://', 'wss://'); + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/privy/react/src/test-setup.ts b/privy/react/src/test-setup.ts new file mode 100644 index 0000000..ec24384 --- /dev/null +++ b/privy/react/src/test-setup.ts @@ -0,0 +1,8 @@ +import { vi } from 'vitest'; + +// Stub Privy app ID for tests (avoids undefined errors in components) +vi.stubEnv('VITE_PRIVY_APP_ID', 'test-app-id'); + +// 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/privy/react/src/vite-env.d.ts b/privy/react/src/vite-env.d.ts new file mode 100644 index 0000000..5cea1e9 --- /dev/null +++ b/privy/react/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_PRIVY_APP_ID: string; + readonly VITE_HELIUS_RPC_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/privy/react/tsconfig.json b/privy/react/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/privy/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/privy/react/tsconfig.node.json b/privy/react/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/privy/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/privy/react/vite.config.ts b/privy/react/vite.config.ts new file mode 100644 index 0000000..71ba862 --- /dev/null +++ b/privy/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/privy/react/vitest.integration.config.ts b/privy/react/vitest.integration.config.ts new file mode 100644 index 0000000..6c319da --- /dev/null +++ b/privy/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, + }, +}); diff --git a/privy/scripts/.env.example b/privy/scripts/.env.example new file mode 100644 index 0000000..685e912 --- /dev/null +++ b/privy/scripts/.env.example @@ -0,0 +1,3 @@ +HELIUS_RPC_URL= +RECIPIENT_ADDRESS= +TEST_MINT= diff --git a/privy/scripts/README.md b/privy/scripts/README.md new file mode 100644 index 0000000..2dba785 --- /dev/null +++ b/privy/scripts/README.md @@ -0,0 +1,34 @@ +# Setup Scripts + +Standalone setup scripts for creating test mints and funding wallets on devnet. These use a local Solana keypair (`~/.config/solana/id.json`) and don't require Privy credentials. + +Shared by both the [Node.js](../nodejs/) and [React](../react/) examples. + +## Setup + +```bash +npm install +cp .env.example .env +# Set HELIUS_RPC_URL +``` + +## Scripts + +| Command | What it does | +| ------- | ----------- | +| `npm run mint:spl-and-wrap [amount] [decimals]` | Create T22 mint with interface PDA, mint tokens, wrap, and transfer to recipient. Defaults: 100 tokens, 9 decimals. | +| `npm run mint:spl [amount] [decimals]` | Mint SPL/T22 tokens to an existing mint. Defaults: 100 tokens, 9 decimals. | +| `npm run register:spl-interface ` | Register an interface PDA on an existing mint. Required for wrap/unwrap. | + +All scripts accept CLI arguments or fall back to env vars (`RECIPIENT_ADDRESS`, `TEST_MINT`). + +## Example: set up a test token for the React app + +```bash +# 1. Create a funded light-token mint and send tokens to your Privy wallet +npm run mint:spl-and-wrap 100 9 + +# 2. Note the mint address from the output, then start the React app +cd ../react +VITE_HELIUS_RPC_URL=... npm run dev +``` diff --git a/privy/scripts/package.json b/privy/scripts/package.json new file mode 100644 index 0000000..e5f9729 --- /dev/null +++ b/privy/scripts/package.json @@ -0,0 +1,23 @@ +{ + "name": "privy-scripts", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "mint:spl-and-wrap": "tsx src/mint-spl-and-wrap.ts", + "mint:spl-and-wrap:spl": "tsx src/mint-spl-and-wrap.ts --spl", + "mint:spl": "tsx src/mint-spl.ts", + "register:spl-interface": "tsx src/register-spl-interface.ts" + }, + "dependencies": { + "@lightprotocol/compressed-token": "beta", + "@lightprotocol/stateless.js": "beta", + "@solana/spl-token": "^0.4.13", + "@solana/web3.js": "1.98.4", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.8.3" + } +} diff --git a/privy/scripts/src/mint-spl-and-wrap.ts b/privy/scripts/src/mint-spl-and-wrap.ts new file mode 100644 index 0000000..b2b2759 --- /dev/null +++ b/privy/scripts/src/mint-spl-and-wrap.ts @@ -0,0 +1,129 @@ +import 'dotenv/config'; +import {Keypair, PublicKey} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import { + createMintInterface, + createAtaInterfaceIdempotent, + getAssociatedTokenAddressInterface, + wrap, + transferInterface, +} from '@lightprotocol/compressed-token'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createAssociatedTokenAccount, + mintTo, +} from '@solana/spl-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +const createLightTokenMint = async ( + decimals: number, + initialAmount: number, + recipientAddress: string, + tokenProgramId: PublicKey = TOKEN_2022_PROGRAM_ID, +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + // Creates on-chain SPL or T22 mint and registers SPL interface PDA in one transaction. + // SPL interface PDA enables wrap/unwrap between SPL/T22 and light-token. + const mintKeypair = Keypair.generate(); + const { mint, transactionSignature } = await createMintInterface( + connection, + payer, + payer, + null, + decimals, + mintKeypair, + undefined, + tokenProgramId, + ); + + console.log('Mint:', mint.toBase58()); + console.log('Create signature:', transactionSignature); + + if (initialAmount > 0) { + const recipient = new PublicKey(recipientAddress); + const tokenAmount = BigInt(Math.floor(initialAmount * Math.pow(10, decimals))); + + // 1. Mint SPL/T22 tokens to payer's associated token account (payer can sign) + const payerSplAta = await createAssociatedTokenAccount( + connection, + payer, + mint, + payer.publicKey, + undefined, + tokenProgramId, + ); + await mintTo( + connection, + payer, + mint, + payerSplAta, + payer, + tokenAmount, + [], + undefined, + tokenProgramId, + ); + console.log('Minted', initialAmount, 'tokens'); + + // 2. Wrap SPL/T22 → payer's light associated token account + await createAtaInterfaceIdempotent(connection, payer, mint, payer.publicKey); + const payerLightAta = getAssociatedTokenAddressInterface(mint, payer.publicKey); + await wrap(connection, payer, payerSplAta, payerLightAta, payer, mint, tokenAmount); + console.log('Wrapped into payer light associated token account'); + + // 3. Transfer payer's light associated token account → recipient's light associated token account + await createAtaInterfaceIdempotent(connection, payer, mint, recipient); + const recipientLightAta = getAssociatedTokenAddressInterface(mint, recipient); + await transferInterface( + connection, + payer, + payerLightAta, + mint, + recipientLightAta, + payer, + tokenAmount, + ); + console.log('Transferred', initialAmount, 'tokens to', recipientAddress); + } + + return { + mintAddress: mint.toBase58(), + signature: transactionSignature, + }; +}; + +export default createLightTokenMint; + +// --- main --- +// Usage: npm run mint:spl-and-wrap [recipient] [amount] [decimals] +// npm run mint:spl-and-wrap:spl [recipient] [amount] [decimals] +// Falls back to RECIPIENT_ADDRESS env var. +// Pass --spl to create an SPL mint instead of T22. +const useSpl = process.argv.includes('--spl'); +const positionalArgs = process.argv.slice(2).filter(a => a !== '--spl'); +const recipient = positionalArgs[0] || process.env.RECIPIENT_ADDRESS; +const amount = Number(positionalArgs[1] || 100); +const decimals = Number(positionalArgs[2] || 9); +const tokenProgramId = useSpl ? TOKEN_PROGRAM_ID : TOKEN_2022_PROGRAM_ID; + +if (!recipient) { + console.error('Usage: npm run mint:spl-and-wrap [amount] [decimals]'); + console.error(' npm run mint:spl-and-wrap:spl [amount] [decimals]'); + console.error(' or set RECIPIENT_ADDRESS in .env'); + process.exit(1); +} + +console.log('Token program:', useSpl ? 'SPL' : 'T22'); +createLightTokenMint(decimals, amount, recipient, tokenProgramId) + .then((result) => console.log('Mint result:', result)) + .catch(console.error); diff --git a/privy/scripts/src/mint-spl.ts b/privy/scripts/src/mint-spl.ts new file mode 100644 index 0000000..d84829d --- /dev/null +++ b/privy/scripts/src/mint-spl.ts @@ -0,0 +1,100 @@ +import 'dotenv/config'; +import {Keypair, PublicKey, Transaction, ComputeBudgetProgram, sendAndConfirmTransaction} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import { + getAssociatedTokenAddressSync, + createAssociatedTokenAccountInstruction, + createMintToInstruction, + getAccount, + TokenAccountNotFoundError, +} from '@solana/spl-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +const mintSplTokens = async ( + mintAddress: string, + recipientAddress: string, + amount: number, + decimals: number, +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + const mint = new PublicKey(mintAddress); + const recipient = new PublicKey(recipientAddress); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Auto-detect token program (SPL vs T22) from mint account owner + const mintAccountInfo = await connection.getAccountInfo(mint); + if (!mintAccountInfo) throw new Error(`Mint account ${mintAddress} not found`); + const tokenProgramId = mintAccountInfo.owner; + console.log('Detected token program:', tokenProgramId.toBase58()); + + // Derive recipient associated token account for the given token program (SPL or T22) + const recipientAta = getAssociatedTokenAddressSync(mint, recipient, false, tokenProgramId); + + // Build transaction + const transaction = new Transaction(); + transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 300_000})); + + // Create associated token account if it doesn't exist + try { + await getAccount(connection, recipientAta, undefined, tokenProgramId); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + transaction.add( + createAssociatedTokenAccountInstruction(payer.publicKey, recipientAta, recipient, mint, tokenProgramId) + ); + } else { + throw e; + } + } + + // Add mint-to instruction + transaction.add( + createMintToInstruction( + mint, + recipientAta, + payer.publicKey, + tokenAmount, + [], + tokenProgramId, + ) + ); + + // Send transaction + const signature = await sendAndConfirmTransaction( + connection, + transaction, + [payer], + {commitment: 'confirmed'} + ); + + return signature; +}; + +export default mintSplTokens; + +// --- main --- +// Usage: npm run mint:spl [amount] [decimals] +// Falls back to TEST_MINT and RECIPIENT_ADDRESS env vars. +const mint = process.argv[2] || process.env.TEST_MINT; +const recipient = process.argv[3] || process.env.RECIPIENT_ADDRESS; +const amount = Number(process.argv[4] || 100); +const decimals = Number(process.argv[5] || 9); + +if (!mint || !recipient) { + console.error('Usage: npm run mint:spl [amount] [decimals]'); + console.error(' or set TEST_MINT and RECIPIENT_ADDRESS in .env'); + process.exit(1); +} + +mintSplTokens(mint, recipient, amount, decimals) + .then((result) => console.log('Mint SPL signature:', result)) + .catch(console.error); diff --git a/privy/scripts/src/register-spl-interface.ts b/privy/scripts/src/register-spl-interface.ts new file mode 100644 index 0000000..5774218 --- /dev/null +++ b/privy/scripts/src/register-spl-interface.ts @@ -0,0 +1,45 @@ +import 'dotenv/config'; +import {Keypair, PublicKey} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {createSplInterface} from '@lightprotocol/compressed-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +// Add to existing mints an SPL interface PDA to enable interop with Light Tokens. +// Interface PDA holds SPL/T22 tokens when wrapped to light-token. +const registerSplInterface = async (mintAddress: string, tokenProgramId?: PublicKey) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + const mint = new PublicKey(mintAddress); + const tx = await createSplInterface(connection, payer, mint, undefined, tokenProgramId); + + console.log('Mint:', mint.toBase58()); + console.log('Register SPL interface signature:', tx); + + return tx; +}; + +export {registerSplInterface}; +export default registerSplInterface; + +// --- main --- +// Usage: npm run register:spl-interface +// Falls back to TEST_MINT env var. +const mint = process.argv[2] || process.env.TEST_MINT; + +if (!mint) { + console.error('Usage: npm run register:spl-interface '); + console.error(' or set TEST_MINT in .env'); + process.exit(1); +} + +registerSplInterface(mint) + .then((result) => console.log('Result:', result)) + .catch(console.error); diff --git a/privy/scripts/tsconfig.json b/privy/scripts/tsconfig.json new file mode 100644 index 0000000..10aedef --- /dev/null +++ b/privy/scripts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +}