From a81793b71ad0f3e8d091d882876aa27fa5c2999e Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Wed, 11 Feb 2026 13:45:56 +0000 Subject: [PATCH 01/15] unifyt --- privy/CLAUDE.md | 122 +++++++++ privy/nodejs-privy-light-token/.env.example | 7 + privy/nodejs-privy-light-token/.gitignore | 2 + privy/nodejs-privy-light-token/README.md | 70 +++++ privy/nodejs-privy-light-token/package.json | 29 +++ .../nodejs-privy-light-token/src/balances.ts | 138 ++++++++++ privy/nodejs-privy-light-token/src/config.ts | 31 +++ .../src/get-transaction-history.ts | 44 ++++ .../src/helpers/decompress.ts | 56 ++++ .../src/helpers/mint-light-token.ts | 102 ++++++++ .../src/helpers/mint-spl.ts | 80 ++++++ .../src/helpers/register-spl-interface.ts | 34 +++ privy/nodejs-privy-light-token/src/load.ts | 90 +++++++ .../nodejs-privy-light-token/src/transfer.ts | 123 +++++++++ privy/nodejs-privy-light-token/src/unwrap.ts | 122 +++++++++ privy/nodejs-privy-light-token/src/wrap.ts | 113 ++++++++ privy/nodejs-privy-light-token/tsconfig.json | 13 + privy/react-privy-light-token/.env.example | 2 + privy/react-privy-light-token/index.html | 13 + privy/react-privy-light-token/package.json | 33 +++ privy/react-privy-light-token/src/App.tsx | 129 +++++++++ .../src/components/reusables/CopyButton.tsx | 31 +++ .../src/components/reusables/Section.tsx | 15 ++ .../components/sections/TransactionStatus.tsx | 45 ++++ .../src/components/sections/TransferForm.tsx | 244 ++++++++++++++++++ .../src/components/sections/WalletInfo.tsx | 24 ++ .../src/components/ui/Header.tsx | 30 +++ .../src/hooks/index.ts | 5 + .../src/hooks/useLightTokenBalances.ts | 133 ++++++++++ .../src/hooks/useTransactionHistory.ts | 63 +++++ .../src/hooks/useTransfer.ts | 87 +++++++ .../src/hooks/useUnwrap.ts | 126 +++++++++ .../src/hooks/useWrap.ts | 117 +++++++++ privy/react-privy-light-token/src/index.css | 33 +++ privy/react-privy-light-token/src/main.tsx | 48 ++++ .../react-privy-light-token/src/vite-env.d.ts | 10 + privy/react-privy-light-token/tsconfig.json | 21 ++ .../tsconfig.node.json | 10 + privy/react-privy-light-token/vite.config.ts | 8 + 39 files changed, 2403 insertions(+) create mode 100644 privy/CLAUDE.md create mode 100644 privy/nodejs-privy-light-token/.env.example create mode 100644 privy/nodejs-privy-light-token/.gitignore create mode 100644 privy/nodejs-privy-light-token/README.md create mode 100644 privy/nodejs-privy-light-token/package.json create mode 100644 privy/nodejs-privy-light-token/src/balances.ts create mode 100644 privy/nodejs-privy-light-token/src/config.ts create mode 100644 privy/nodejs-privy-light-token/src/get-transaction-history.ts create mode 100644 privy/nodejs-privy-light-token/src/helpers/decompress.ts create mode 100644 privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts create mode 100644 privy/nodejs-privy-light-token/src/helpers/mint-spl.ts create mode 100644 privy/nodejs-privy-light-token/src/helpers/register-spl-interface.ts create mode 100644 privy/nodejs-privy-light-token/src/load.ts create mode 100644 privy/nodejs-privy-light-token/src/transfer.ts create mode 100644 privy/nodejs-privy-light-token/src/unwrap.ts create mode 100644 privy/nodejs-privy-light-token/src/wrap.ts create mode 100644 privy/nodejs-privy-light-token/tsconfig.json create mode 100644 privy/react-privy-light-token/.env.example create mode 100644 privy/react-privy-light-token/index.html create mode 100644 privy/react-privy-light-token/package.json create mode 100644 privy/react-privy-light-token/src/App.tsx create mode 100644 privy/react-privy-light-token/src/components/reusables/CopyButton.tsx create mode 100644 privy/react-privy-light-token/src/components/reusables/Section.tsx create mode 100644 privy/react-privy-light-token/src/components/sections/TransactionStatus.tsx create mode 100644 privy/react-privy-light-token/src/components/sections/TransferForm.tsx create mode 100644 privy/react-privy-light-token/src/components/sections/WalletInfo.tsx create mode 100644 privy/react-privy-light-token/src/components/ui/Header.tsx create mode 100644 privy/react-privy-light-token/src/hooks/index.ts create mode 100644 privy/react-privy-light-token/src/hooks/useLightTokenBalances.ts create mode 100644 privy/react-privy-light-token/src/hooks/useTransactionHistory.ts create mode 100644 privy/react-privy-light-token/src/hooks/useTransfer.ts create mode 100644 privy/react-privy-light-token/src/hooks/useUnwrap.ts create mode 100644 privy/react-privy-light-token/src/hooks/useWrap.ts create mode 100644 privy/react-privy-light-token/src/index.css create mode 100644 privy/react-privy-light-token/src/main.tsx create mode 100644 privy/react-privy-light-token/src/vite-env.d.ts create mode 100644 privy/react-privy-light-token/tsconfig.json create mode 100644 privy/react-privy-light-token/tsconfig.node.json create mode 100644 privy/react-privy-light-token/vite.config.ts diff --git a/privy/CLAUDE.md b/privy/CLAUDE.md new file mode 100644 index 0000000..0758894 --- /dev/null +++ b/privy/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +Two example apps demonstrating Privy wallet integration with Light Token on Solana devnet. Both apps show transfer, wrap (SPL to light-token), and unwrap (light-token to SPL T22) flows using Privy-managed wallets for transaction signing. + +## Sub-projects + +### `nodejs-privy-light-token/` — Server-side (Node.js) + +Backend scripts using `@privy-io/node` with server-side wallet signing via `TREASURY_AUTHORIZATION_KEY`. Each script is a standalone operation run with `tsx`. + +### `react-privy-light-token/` — Client-side (React + Vite) + +Browser app using `@privy-io/react-auth` with client-side wallet signing via `useSignTransaction`. Privy creates embedded Solana wallets on login. Vite dev server with Tailwind CSS v4 and `vite-plugin-node-polyfills` for Buffer. + +## Build and run + +### Node.js scripts + +```bash +cd nodejs-privy-light-token +npm install +cp .env.example .env # fill in all values + +npm run transfer # light-token ATA → ATA transfer +npm run wrap # SPL → light-token ATA +npm run unwrap # light-token ATA → SPL T22 +npm run mint:light-token # create light-token mint + mint tokens (uses filesystem wallet) +npm run mint:spl # create SPL mint + mint tokens (uses filesystem wallet) +``` + +### React app + +```bash +cd react-privy-light-token +npm install +cp .env.example .env # fill in VITE_PRIVY_APP_ID and VITE_HELIUS_RPC_URL + +npm run dev # start Vite dev server +npm run build # production build +npm run preview # preview production build +``` + +## Architecture + +### Privy signing pattern + +Both apps follow the same flow: build an unsigned `Transaction`, serialize with `requireAllSignatures: false`, sign via Privy, deserialize, and send with `sendRawTransaction`. + +**Node.js** — signs via `privy.wallets().solana().signTransaction(walletId, { transaction, authorization_context })`. Requires `TREASURY_WALLET_ID` and `TREASURY_AUTHORIZATION_KEY`. + +**React** — signs via `useSignTransaction` hook: `signTransaction({ transaction, wallet, chain: 'solana:devnet' })`. Privy handles embedded wallet key management client-side. + +### Transaction routing (React `TransferForm`) + +The `TransferForm` component routes actions based on `TokenBalance.isLightToken`: +- Light-token balance → `useTransfer` → `createTransferInterfaceInstruction` +- SPL balance → `useWrap` → `createWrapInstruction` (wraps to own light-token ATA) +- SOL → display only, no transfer action + +### React hooks (`src/hooks/`) + +Each hook returns `{ actionFn, isLoading }` and accepts `{ params, wallet, signTransaction }`: +- `useTransfer` — light-token ATA to ATA transfer +- `useWrap` — SPL to light-token (creates light-token ATA idempotently, verifies SPL balance) +- `useUnwrap` — light-token to SPL T22 (creates T22 ATA if missing) +- `useLightTokenBalances` — fetches SOL, SPL (Token Program), and light-token (T22) balances by parsing raw account data +- `useTransactionHistory` — queries `getSignaturesForOwnerInterface` + +### Node.js modules (`src/`) + +Standalone async functions, each creating their own `PrivyClient` and `createRpc`: +- `transfer.ts` — `createTransferInterfaceInstruction` +- `wrap.ts` — `createWrapInstruction` with SPL interface lookup +- `unwrap.ts` — `createUnwrapInstruction` from `@lightprotocol/compressed-token/unified` +- `balances.ts` — parses T22 and SPL account data for balances +- `get-transaction-history.ts` — `getSignaturesForOwnerInterface` +- `helpers/mint-light-token.ts` — creates light-token mint with validity proof (uses filesystem wallet, not Privy) +- `helpers/mint-spl.ts` — creates standard SPL mint (uses filesystem wallet, not Privy) + +## Environment variables + +### Node.js (`.env`) + +| Variable | Required | Purpose | +|---|---|---| +| `PRIVY_APP_ID` | Yes | Privy application ID | +| `PRIVY_APP_SECRET` | Yes | Privy server-side secret | +| `TREASURY_WALLET_ID` | Yes | Privy wallet ID for signing | +| `TREASURY_WALLET_ADDRESS` | Yes | Public key of treasury wallet | +| `TREASURY_AUTHORIZATION_KEY` | Yes | Private key authorizing wallet operations | +| `HELIUS_RPC_URL` | Yes | Helius RPC endpoint (devnet) | +| `TEST_MINT` | No | Token mint address for scripts | + +### React (`.env`) + +| Variable | Required | Purpose | +|---|---|---| +| `VITE_PRIVY_APP_ID` | Yes | Privy application ID | +| `VITE_HELIUS_RPC_URL` | Yes | Helius RPC endpoint (devnet) | + +## Key dependencies + +- `@privy-io/node` ^0.1.0-alpha.2 — server-side Privy SDK +- `@privy-io/react-auth` ^3.9.1 — client-side Privy SDK +- `@lightprotocol/compressed-token` beta — light-token instructions (also exports `/unified` subpath for unwrap) +- `@lightprotocol/stateless.js` beta — RPC client (`createRpc`) +- `@solana/web3.js` 1.98.4 — Solana web3 v1 +- `@solana/spl-token` ^0.4.13 — SPL token operations (T22 ATA creation, balance checks) +- `@solana/kit` ^5.5.1 — Solana RPC for Privy provider config (React only) + +## Important patterns + +- Wrap and unwrap require `ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 })`. Transfer does not. +- Wrap requires `getSplInterfaceInfos` to find the initialized SPL interface for the mint. +- Unwrap imports from `@lightprotocol/compressed-token/unified` (not the main export). +- Helper mint scripts use filesystem wallet (`~/.config/solana/id.json`), not Privy, because they need a keypair signer for mint authority. +- The React app derives WebSocket URL from RPC URL by replacing `https://` with `wss://`. +- Both apps target `solana:devnet` (CAIP-2 chain ID `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`). \ No newline at end of file diff --git a/privy/nodejs-privy-light-token/.env.example b/privy/nodejs-privy-light-token/.env.example new file mode 100644 index 0000000..81933c5 --- /dev/null +++ b/privy/nodejs-privy-light-token/.env.example @@ -0,0 +1,7 @@ +PRIVY_APP_ID= +PRIVY_APP_SECRET= +TREASURY_WALLET_ID= +TREASURY_WALLET_ADDRESS= +TREASURY_AUTHORIZATION_KEY= +HELIUS_RPC_URL= +TEST_MINT= diff --git a/privy/nodejs-privy-light-token/.gitignore b/privy/nodejs-privy-light-token/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/privy/nodejs-privy-light-token/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/privy/nodejs-privy-light-token/README.md b/privy/nodejs-privy-light-token/README.md new file mode 100644 index 0000000..000149d --- /dev/null +++ b/privy/nodejs-privy-light-token/README.md @@ -0,0 +1,70 @@ +# Privy + Light Token (Node.js) + +Server-side light-token operations using [Privy](https://privy.io) server wallets on Solana devnet. + +Privy server wallets sign transactions without exposing private keys. This example demonstrates transfers, balance queries, wrapping/unwrapping, and compressed token loading — all signed via the Privy API. + +## Prerequisites + +- Node.js 18+ +- [Solana CLI](https://docs.solana.com/cli/install-solana-cli-tools) with a keypair at `~/.config/solana/id.json` (for helper scripts) +- Privy account with a server wallet configured +- [Helius](https://helius.dev) API key for devnet RPC + +## Setup + +```bash +npm install +cp .env.example .env +# Fill in .env with your credentials +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `PRIVY_APP_ID` | Privy application ID | +| `PRIVY_APP_SECRET` | Privy application secret | +| `TREASURY_WALLET_ID` | Privy server wallet ID | +| `TREASURY_WALLET_ADDRESS` | Solana address of the server wallet | +| `TREASURY_AUTHORIZATION_KEY` | EC private key for ECDSA authorization | +| `HELIUS_RPC_URL` | Helius devnet RPC endpoint | +| `TEST_MINT` | Light-token mint address to use | + +## Scripts + +### Core operations (Privy-signed) + +| Script | Command | Description | +|--------|---------|-------------| +| `transfer` | `npm run transfer` | Transfer light-token ATA to ATA. Pass `--type=t22` to unwrap to SPL T22 instead. Auto-loads cold balance before transfer. | +| `load` | `npm run load` | Load compressed tokens (cold) into light-token ATA (hot). | +| `wrap` | `npm run wrap` | Wrap SPL T22 tokens into light-token ATA. | +| `unwrap` | `npm run unwrap` | Unwrap light-token ATA to SPL T22 ATA. | +| `balances` | `npm run balances` | Query unified balance + breakdown (hot, cold, T22, SOL). | +| `history` | `npm run history` | Get transaction history for the treasury wallet. | + +### Helper scripts (filesystem wallet) + +These use the local Solana keypair at `~/.config/solana/id.json`, not Privy. + +| Script | Command | Description | +|--------|---------|-------------| +| `mint:light-token` | `npm run mint:light-token` | Create a light-token mint and mint tokens. | +| `mint:spl` | `npm run mint:spl` | Mint SPL tokens for an existing SPL mint. | +| `register:spl-interface` | `npm run register:spl-interface` | Register SPL interface (required for wrap/unwrap). | +| `decompress` | `npm run decompress` | Decompress light-token ATA to SPL T22 ATA. | + +## Flows + +### Transfer flow + +``` +mint:light-token → load (if cold balance) → transfer → history → balances +``` + +### Wrap/unwrap flow + +``` +mint:light-token → register:spl-interface → decompress → wrap → unwrap → balances +``` diff --git a/privy/nodejs-privy-light-token/package.json b/privy/nodejs-privy-light-token/package.json new file mode 100644 index 0000000..306bb78 --- /dev/null +++ b/privy/nodejs-privy-light-token/package.json @@ -0,0 +1,29 @@ +{ + "name": "nodejs-privy-light-token", + "version": "1.0.0", + "type": "module", + "scripts": { + "transfer": "tsx src/transfer.ts", + "load": "tsx src/load.ts", + "wrap": "tsx src/wrap.ts", + "unwrap": "tsx src/unwrap.ts", + "balances": "tsx src/balances.ts", + "history": "tsx src/get-transaction-history.ts", + "mint:light-token": "tsx src/helpers/mint-light-token.ts", + "mint:spl": "tsx src/helpers/mint-spl.ts", + "register:spl-interface": "tsx src/helpers/register-spl-interface.ts", + "decompress": "tsx src/helpers/decompress.ts" + }, + "dependencies": { + "@privy-io/node": "^0.1.0-alpha.2", + "@lightprotocol/compressed-token": "beta", + "@lightprotocol/stateless.js": "beta", + "@solana/web3.js": "1.98.4", + "@solana/spl-token": "^0.4.13", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.8.3" + } +} diff --git a/privy/nodejs-privy-light-token/src/balances.ts b/privy/nodejs-privy-light-token/src/balances.ts new file mode 100644 index 0000000..1b82818 --- /dev/null +++ b/privy/nodejs-privy-light-token/src/balances.ts @@ -0,0 +1,138 @@ +import 'dotenv/config'; +import {PublicKey, LAMPORTS_PER_SOL} from '@solana/web3.js'; +import {TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; +import {createRpc} from '@lightprotocol/stateless.js'; +import { + getAtaInterface, + getAssociatedTokenAddressInterface, +} from '@lightprotocol/compressed-token/unified'; +import {HELIUS_RPC_URL, TREASURY_WALLET_ADDRESS, TEST_MINT} from './config.js'; + +interface BalanceBreakdown { + sol: number; + token?: { + mint: string; + unified: number; + hasColdBalance: boolean; + decimals: number; + hot: number; + cold: number; + splT22: number; + }; +} + +export async function getBalances( + ownerAddress: string, + mintAddress?: string, +): Promise { + const rpc = createRpc(HELIUS_RPC_URL); + const owner = new PublicKey(ownerAddress); + + // SOL balance + let solLamports = 0; + try { + solLamports = await rpc.getBalance(owner); + } catch (e) { + console.error('Failed to fetch SOL balance:', e); + } + + const result: BalanceBreakdown = { + sol: solLamports / LAMPORTS_PER_SOL, + }; + + if (!mintAddress) { + return result; + } + + const mint = new PublicKey(mintAddress); + const ata = getAssociatedTokenAddressInterface(mint, owner); + + const decimals = 9; + let hasColdBalance = false; + let hot = 0; + let cold = 0; + let splT22 = 0; + + // 1. Hot balance (light-token ATA on-chain) + try { + const {parsed, isCold} = await getAtaInterface(rpc, ata, owner, mint); + hot = toUiAmount(parsed.amount, decimals); + hasColdBalance = isCold; + } catch { + // ATA may not exist yet + } + + // 2. Cold balance (compressed token accounts) + try { + const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner, { + mint, + }); + for (const item of compressed.value.items) { + cold += toUiAmount(item.balance, decimals); + } + } catch { + // No compressed accounts + } + + // 3. SPL T22 balance (standard Token-2022 accounts) + try { + const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_2022_PROGRAM_ID, + }); + for (const {account} of t22Accounts.value) { + const data = account.data; + const buf = + data instanceof Buffer + ? data + : typeof data === 'object' && 'length' in data + ? Buffer.from(data as Uint8Array) + : null; + if (!buf || buf.length < 72) continue; + + const accountMint = new PublicKey(buf.subarray(0, 32)); + if (!accountMint.equals(mint)) continue; + + const amount = buf.readBigUInt64LE(64); + splT22 += toUiAmount(amount, decimals); + } + } catch { + // No T22 accounts + } + + const unified = hot + cold; + + result.token = { + mint: mintAddress, + unified, + hasColdBalance, + decimals, + hot, + cold, + splT22, + }; + + return result; +} + +function toUiAmount(raw: bigint | {toNumber: () => number}, decimals: number): number { + const value = typeof raw === 'bigint' ? Number(raw) : raw.toNumber(); + return value / 10 ** decimals; +} + +export default getBalances; + +// --- main --- +getBalances(TREASURY_WALLET_ADDRESS, TEST_MINT) + .then((b) => { + console.log(`SOL: ${b.sol}`); + if (b.token) { + const t = b.token; + const short = t.mint.slice(0, 6) + '...'; + console.log(`Token ${short} (unified): ${t.unified}`); + console.log(` Hot (light-token ATA): ${t.hot}`); + console.log(` Cold (compressed): ${t.cold}`); + console.log(` SPL T22: ${t.splT22}`); + console.log(` Has cold balance: ${t.hasColdBalance}`); + } + }) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/config.ts b/privy/nodejs-privy-light-token/src/config.ts new file mode 100644 index 0000000..8c4cdc0 --- /dev/null +++ b/privy/nodejs-privy-light-token/src/config.ts @@ -0,0 +1,31 @@ +export const PRIVY_APP_ID = process.env.PRIVY_APP_ID!; +export const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET!; + +export const TREASURY_WALLET_ID = process.env.TREASURY_WALLET_ID!; +export const TREASURY_WALLET_ADDRESS = process.env.TREASURY_WALLET_ADDRESS!; +export const TREASURY_AUTHORIZATION_KEY = process.env.TREASURY_AUTHORIZATION_KEY!; + +export const HELIUS_RPC_URL = process.env.HELIUS_RPC_URL!; +export const TEST_MINT = process.env.TEST_MINT || ''; + +export const SOLANA_CAIP2_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; + +// Default values for standalone scripts +export const DEFAULT_TEST_RECIPIENT = process.env.DEFAULT_TEST_RECIPIENT || TREASURY_WALLET_ADDRESS; +export const DEFAULT_AMOUNT = process.env.DEFAULT_AMOUNT || '0.001'; +export const DEFAULT_DECIMALS = process.env.DEFAULT_DECIMALS || '9'; + +// Validate required env vars +if (!PRIVY_APP_ID || !PRIVY_APP_SECRET) { + throw new Error('Missing Privy credentials (PRIVY_APP_ID, PRIVY_APP_SECRET)'); +} + +if (!TREASURY_WALLET_ID || !TREASURY_WALLET_ADDRESS || !TREASURY_AUTHORIZATION_KEY) { + throw new Error( + 'Missing treasury wallet configuration. Please add TREASURY_AUTHORIZATION_KEY to .env' + ); +} + +if (!HELIUS_RPC_URL) { + throw new Error('Missing HELIUS_RPC_URL'); +} diff --git a/privy/nodejs-privy-light-token/src/get-transaction-history.ts b/privy/nodejs-privy-light-token/src/get-transaction-history.ts new file mode 100644 index 0000000..b223ebe --- /dev/null +++ b/privy/nodejs-privy-light-token/src/get-transaction-history.ts @@ -0,0 +1,44 @@ +import 'dotenv/config'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {PublicKey} from '@solana/web3.js'; + +const getTransactionHistory = async ( + ownerAddress: string, + limit: number = 10, +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + const owner = new PublicKey(ownerAddress); + + // Get light-token interface signatures + const result = await connection.getSignaturesForOwnerInterface(owner); + + if (!result.signatures || result.signatures.length === 0) { + return { + count: 0, + transactions: [], + }; + } + + const limitedSignatures = result.signatures.slice(0, limit); + + const transactions = limitedSignatures.map((sig) => ({ + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime ?? 0, + timestamp: sig.blockTime ? new Date(sig.blockTime * 1000).toISOString() : '', + })); + + return { + count: result.signatures.length, + transactions, + }; +}; + +export default getTransactionHistory; + +// --- main --- +import {TREASURY_WALLET_ADDRESS} from './config.js'; +getTransactionHistory(TREASURY_WALLET_ADDRESS) + .then((result) => console.log('Transaction history:', JSON.stringify(result, null, 2))) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/helpers/decompress.ts b/privy/nodejs-privy-light-token/src/helpers/decompress.ts new file mode 100644 index 0000000..a1ba904 --- /dev/null +++ b/privy/nodejs-privy-light-token/src/helpers/decompress.ts @@ -0,0 +1,56 @@ +import 'dotenv/config'; +import {Keypair, PublicKey} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {decompressInterface} from '@lightprotocol/compressed-token'; +import {createAssociatedTokenAccount, TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +const decompress = async (mintAddress: string, amount: bigint) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet (not Privy — helper script) + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + const mint = new PublicKey(mintAddress); + + // Create T22 ATA if needed + try { + await createAssociatedTokenAccount( + connection, + payer, + mint, + payer.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + console.log('Created T22 ATA'); + } catch (e: any) { + // ATA already exists + if (!e.message?.includes('already in use')) { + throw e; + } + console.log('T22 ATA already exists'); + } + + const tx = await decompressInterface(connection, payer, payer, mint, amount); + + console.log('Mint:', mint.toBase58()); + console.log('Decompress signature:', tx); + + return tx; +}; + +export {decompress}; +export default decompress; + +// --- main --- +import {TEST_MINT} from '../config.js'; +const AMOUNT = BigInt(10 * 10 ** 9); // 10 tokens with 9 decimals +decompress(TEST_MINT, AMOUNT) + .then((result) => console.log('Result:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts b/privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts new file mode 100644 index 0000000..b4a13df --- /dev/null +++ b/privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts @@ -0,0 +1,102 @@ +import 'dotenv/config'; +import {Keypair, PublicKey, Transaction, ComputeBudgetProgram, sendAndConfirmTransaction} from '@solana/web3.js'; +import {createRpc, getBatchAddressTreeInfo, selectStateTreeInfo, CTOKEN_PROGRAM_ID} from '@lightprotocol/stateless.js'; +import { + createMintInstruction, + createAtaInterface, + mintToInterface, + getAssociatedTokenAddressInterface, +} from '@lightprotocol/compressed-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +const COMPRESSED_MINT_SEED = Buffer.from('compressed_mint'); + +function findMintAddress(mintSigner: PublicKey): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [COMPRESSED_MINT_SEED, mintSigner.toBuffer()], + CTOKEN_PROGRAM_ID, + ); +} + +const createLightTokenMint = async ( + decimals: number, + initialAmount: number, + recipientAddress: string +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet (not Privy — helper script) + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + const mintSigner = Keypair.generate(); + const addressTreeInfo = getBatchAddressTreeInfo(); + const stateTreeInfo = selectStateTreeInfo(await connection.getStateTreeInfos()); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const validityProof = await connection.getValidityProofV2( + [], + [{address: mintPda.toBytes(), treeInfo: addressTreeInfo}], + ); + + const ix = createMintInstruction( + mintSigner.publicKey, + decimals, + payer.publicKey, + null, + payer.publicKey, + validityProof, + addressTreeInfo, + stateTreeInfo, + ); + + const transaction = new Transaction().add( + ComputeBudgetProgram.setComputeUnitLimit({units: 1_000_000}), + ix, + ); + + const createSignature = await sendAndConfirmTransaction( + connection, + transaction, + [payer, mintSigner], + {commitment: 'confirmed'} + ); + + console.log('Mint:', mintPda.toBase58()); + console.log('Create signature:', createSignature); + + // If initialAmount > 0, create ATA and mint tokens + if (initialAmount > 0) { + const recipient = new PublicKey(recipientAddress); + await createAtaInterface(connection, payer, mintPda, recipient); + const recipientAta = getAssociatedTokenAddressInterface(mintPda, recipient); + + await mintToInterface( + connection, + payer, + mintPda, + recipientAta, + payer, + initialAmount * Math.pow(10, decimals), + ); + + console.log('Minted', initialAmount, 'tokens to', recipientAddress); + } + + return { + mintAddress: mintPda.toBase58(), + signature: createSignature, + }; +}; + +export default createLightTokenMint; + +// --- main --- +import {TREASURY_WALLET_ADDRESS} from '../config.js'; +createLightTokenMint(9, 100, TREASURY_WALLET_ADDRESS) + .then((result) => console.log('Mint result:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/helpers/mint-spl.ts b/privy/nodejs-privy-light-token/src/helpers/mint-spl.ts new file mode 100644 index 0000000..f14f622 --- /dev/null +++ b/privy/nodejs-privy-light-token/src/helpers/mint-spl.ts @@ -0,0 +1,80 @@ +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(amount * Math.pow(10, decimals)); + + // Get recipient ATA + const recipientAta = getAssociatedTokenAddressSync(mint, recipient); + + // Build transaction + const transaction = new Transaction(); + transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 300_000})); + + // Create ATA if it doesn't exist + try { + await getAccount(connection, recipientAta); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + transaction.add( + createAssociatedTokenAccountInstruction(payer.publicKey, recipientAta, recipient, mint) + ); + } else { + throw e; + } + } + + // Add mint-to instruction + transaction.add( + createMintToInstruction( + mint, + recipientAta, + payer.publicKey, + tokenAmount + ) + ); + + // Send transaction + const signature = await sendAndConfirmTransaction( + connection, + transaction, + [payer], + {commitment: 'confirmed'} + ); + + return signature; +}; + +export default mintSplTokens; + +// --- main --- +import {TREASURY_WALLET_ADDRESS, TEST_MINT} from '../config.js'; +mintSplTokens(TEST_MINT, TREASURY_WALLET_ADDRESS, 100, 9) + .then((result) => console.log('Mint SPL signature:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/helpers/register-spl-interface.ts b/privy/nodejs-privy-light-token/src/helpers/register-spl-interface.ts new file mode 100644 index 0000000..133a283 --- /dev/null +++ b/privy/nodejs-privy-light-token/src/helpers/register-spl-interface.ts @@ -0,0 +1,34 @@ +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'; + +const registerSplInterface = async (mintAddress: string) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet (not Privy — helper script) + 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); + + console.log('Mint:', mint.toBase58()); + console.log('Register SPL interface signature:', tx); + + return tx; +}; + +export {registerSplInterface}; +export default registerSplInterface; + +// --- main --- +import {TEST_MINT} from '../config.js'; +registerSplInterface(TEST_MINT) + .then((result) => console.log('Result:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/load.ts b/privy/nodejs-privy-light-token/src/load.ts new file mode 100644 index 0000000..59971ef --- /dev/null +++ b/privy/nodejs-privy-light-token/src/load.ts @@ -0,0 +1,90 @@ +import 'dotenv/config'; +import {PrivyClient} from '@privy-io/node'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {PublicKey, Transaction} from '@solana/web3.js'; +import { + getAssociatedTokenAddressInterface, +} from '@lightprotocol/compressed-token'; +import { + getAtaInterface, + createLoadAtaInstructionsFromInterface, +} from '@lightprotocol/compressed-token/unified'; +import { + TREASURY_WALLET_ID, + TREASURY_AUTHORIZATION_KEY, + PRIVY_APP_ID, + PRIVY_APP_SECRET, + HELIUS_RPC_URL, +} from './config.js'; + +const loadLightTokens = async ( + ownerAddress: string, + mintAddress: string, +) => { + const connection = createRpc(HELIUS_RPC_URL); + + const privy = new PrivyClient({ + appId: PRIVY_APP_ID, + appSecret: PRIVY_APP_SECRET, + }); + + const ownerPubkey = new PublicKey(ownerAddress); + const mintPubkey = new PublicKey(mintAddress); + + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, ownerPubkey); + + // Query all token sources for the ATA + const ataInfo = await getAtaInterface(connection, lightTokenAta, ownerPubkey, mintPubkey); + + // Consolidate all sources (cold compressed + SPL + T22) into light-token ATA + const ixs = await createLoadAtaInstructionsFromInterface( + connection, + ownerPubkey, + ataInfo, + undefined, + true, // wrap=true: consolidate SPL + T22 + cold into light ATA + ); + + if (ixs.length === 0) { + console.log('Nothing to load'); + return null; + } + + const transaction = new Transaction(); + transaction.add(...ixs); + + const {blockhash} = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = ownerPubkey; + + // Sign with Privy + const signResult = await privy.wallets().solana().signTransaction(TREASURY_WALLET_ID, { + transaction: transaction.serialize({requireAllSignatures: false}), + authorization_context: { + authorization_private_keys: [TREASURY_AUTHORIZATION_KEY] + } + }); + const signedTx = (signResult as any).signed_transaction; + if (!signedTx) { + throw new Error('Privy returned invalid response: ' + JSON.stringify(signResult)); + } + + const signature = await connection.sendRawTransaction(Buffer.from(signedTx, 'base64'), { + skipPreflight: false, + preflightCommitment: 'confirmed' + }); + await connection.confirmTransaction(signature, 'confirmed'); + + return signature; +}; + +export default loadLightTokens; + +// --- main --- +import {TREASURY_WALLET_ADDRESS, TEST_MINT} from './config.js'; + +loadLightTokens(TREASURY_WALLET_ADDRESS, TEST_MINT) + .then((result) => { + if (result) console.log('Load signature:', result); + }) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/transfer.ts b/privy/nodejs-privy-light-token/src/transfer.ts new file mode 100644 index 0000000..7933fba --- /dev/null +++ b/privy/nodejs-privy-light-token/src/transfer.ts @@ -0,0 +1,123 @@ +import 'dotenv/config'; +import {PrivyClient} from '@privy-io/node'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {PublicKey, Transaction} from '@solana/web3.js'; +import { + createTransferInterfaceInstruction, + getAssociatedTokenAddressInterface, +} from '@lightprotocol/compressed-token'; +import { + getAtaInterface, + createLoadAtaInstructionsFromInterface, +} from '@lightprotocol/compressed-token/unified'; +import { + TREASURY_WALLET_ID, + TREASURY_AUTHORIZATION_KEY, + PRIVY_APP_ID, + PRIVY_APP_SECRET, + HELIUS_RPC_URL, +} from './config.js'; + +const signAndSend = async ( + transaction: Transaction, + connection: ReturnType, + privy: PrivyClient, +) => { + const signResult = await privy.wallets().solana().signTransaction(TREASURY_WALLET_ID, { + transaction: transaction.serialize({requireAllSignatures: false}), + authorization_context: { + authorization_private_keys: [TREASURY_AUTHORIZATION_KEY] + } + }); + const signedTx = (signResult as any).signed_transaction; + if (!signedTx) { + throw new Error('Privy returned invalid response: ' + JSON.stringify(signResult)); + } + + const signature = await connection.sendRawTransaction(Buffer.from(signedTx, 'base64'), { + skipPreflight: false, + preflightCommitment: 'confirmed' + }); + await connection.confirmTransaction(signature, 'confirmed'); + return signature; +}; + +const transferLightTokens = async ( + fromAddress: string, + toAddress: string, + tokenMintAddress: string, + amount: number, + decimals: number = 9, +) => { + const connection = createRpc(HELIUS_RPC_URL); + + const privy = new PrivyClient({ + appId: PRIVY_APP_ID, + appSecret: PRIVY_APP_SECRET, + }); + + const fromPubkey = new PublicKey(fromAddress); + const toPubkey = new PublicKey(toAddress); + const mintPubkey = new PublicKey(tokenMintAddress); + + const sourceAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); + + // Consolidate all token sources (cold compressed + SPL + T22) into light-token ATA + try { + const ataInfo = await getAtaInterface(connection, sourceAta, fromPubkey, mintPubkey); + + const loadIxs = await createLoadAtaInstructionsFromInterface( + connection, + fromPubkey, + ataInfo, + undefined, + true, // wrap=true: consolidate SPL + T22 + cold into light ATA + ); + + if (loadIxs.length > 0) { + console.log('Consolidating token sources into light-token ATA...'); + const loadTx = new Transaction(); + loadTx.add(...loadIxs); + + const {blockhash} = await connection.getLatestBlockhash(); + loadTx.recentBlockhash = blockhash; + loadTx.feePayer = fromPubkey; + + const loadSig = await signAndSend(loadTx, connection, privy); + console.log('Load signature:', loadSig); + } + } catch { + // ATA doesn't exist yet — nothing to consolidate + } + + // Transfer light-token ATA to light-token ATA + const destAta = getAssociatedTokenAddressInterface(mintPubkey, toPubkey); + const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); + + const transaction = new Transaction(); + transaction.add( + createTransferInterfaceInstruction(sourceAta, destAta, fromPubkey, tokenAmount), + ); + + const {blockhash} = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = fromPubkey; + + const signature = await signAndSend(transaction, connection, privy); + return signature; +}; + +export default transferLightTokens; + +// --- main --- +import {TREASURY_WALLET_ADDRESS, DEFAULT_TEST_RECIPIENT, TEST_MINT, DEFAULT_AMOUNT, DEFAULT_DECIMALS} from './config.js'; + +transferLightTokens( + TREASURY_WALLET_ADDRESS, + DEFAULT_TEST_RECIPIENT, + TEST_MINT, + parseFloat(DEFAULT_AMOUNT), + parseInt(DEFAULT_DECIMALS), +) + .then((result) => console.log('Transfer signature:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/unwrap.ts b/privy/nodejs-privy-light-token/src/unwrap.ts new file mode 100644 index 0000000..7b41881 --- /dev/null +++ b/privy/nodejs-privy-light-token/src/unwrap.ts @@ -0,0 +1,122 @@ +import 'dotenv/config'; +import {PrivyClient} from '@privy-io/node'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {PublicKey, Transaction, ComputeBudgetProgram} from '@solana/web3.js'; +import { + getAssociatedTokenAddressSync, + createAssociatedTokenAccountInstruction, + getAccount, + TokenAccountNotFoundError, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { + getAssociatedTokenAddressInterface, + getSplInterfaceInfos, +} from '@lightprotocol/compressed-token'; +import {createUnwrapInstruction} from '@lightprotocol/compressed-token/unified'; + +const unwrapTokens = async ( + fromAddress: string, + tokenMintAddress: string, + amount: number, + decimals: number = 9 +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + const privy = new PrivyClient({ + appId: process.env.PRIVY_APP_ID!, + appSecret: process.env.PRIVY_APP_SECRET!, + }); + + const fromPubkey = new PublicKey(fromAddress); + const mintPubkey = new PublicKey(tokenMintAddress); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Source: light-token ATA + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); + + // Destination: SPL T22 ATA + const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, TOKEN_2022_PROGRAM_ID); + + // Get SPL interface info + const splInterfaceInfos = await getSplInterfaceInfos(connection, mintPubkey); + const splInterfaceInfo = splInterfaceInfos.find( + (info) => info.isInitialized, + ); + if (!splInterfaceInfo) throw new Error('No SPL interface found for this mint'); + + // Build transaction + const transaction = new Transaction(); + transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 200_000})); + + // Create SPL T22 ATA if needed + try { + await getAccount(connection, splAta, undefined, TOKEN_2022_PROGRAM_ID); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + transaction.add( + createAssociatedTokenAccountInstruction( + fromPubkey, + splAta, + fromPubkey, + mintPubkey, + TOKEN_2022_PROGRAM_ID, + ), + ); + } else { + throw e; + } + } + + // Build unwrap instruction + const unwrapIx = createUnwrapInstruction( + lightTokenAta, + splAta, + fromPubkey, + mintPubkey, + tokenAmount, + splInterfaceInfo, + decimals, + fromPubkey, + ); + transaction.add(unwrapIx); + + const {blockhash} = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = fromPubkey; + + // Sign with Privy + const signResult = await privy.wallets().solana().signTransaction(process.env.TREASURY_WALLET_ID!, { + transaction: transaction.serialize({requireAllSignatures: false}), + authorization_context: { + authorization_private_keys: [process.env.TREASURY_AUTHORIZATION_KEY!] + } + }); + const signedTx = (signResult as any).signed_transaction; + if (!signedTx) { + throw new Error('Privy returned invalid response: ' + JSON.stringify(signResult)); + } + const signedTransaction = Buffer.from(signedTx, 'base64'); + + // Send transaction + const signature = await connection.sendRawTransaction(signedTransaction, { + skipPreflight: false, + preflightCommitment: 'confirmed' + }); + await connection.confirmTransaction(signature, 'confirmed'); + + return signature; +}; + +export default unwrapTokens; + +// --- main --- +import {TREASURY_WALLET_ADDRESS, TEST_MINT, DEFAULT_AMOUNT, DEFAULT_DECIMALS} from './config.js'; +unwrapTokens( + TREASURY_WALLET_ADDRESS, + TEST_MINT, + parseFloat(DEFAULT_AMOUNT), + parseInt(DEFAULT_DECIMALS), +) + .then((result) => console.log('Unwrap signature:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/wrap.ts b/privy/nodejs-privy-light-token/src/wrap.ts new file mode 100644 index 0000000..9f2b1b5 --- /dev/null +++ b/privy/nodejs-privy-light-token/src/wrap.ts @@ -0,0 +1,113 @@ +import 'dotenv/config'; +import {PrivyClient} from '@privy-io/node'; +import {createRpc, CTOKEN_PROGRAM_ID} from '@lightprotocol/stateless.js'; +import {PublicKey, Transaction, ComputeBudgetProgram} from '@solana/web3.js'; +import {getAssociatedTokenAddressSync, getAccount, TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; +import { + createWrapInstruction, + getAssociatedTokenAddressInterface, + getSplInterfaceInfos, + createAssociatedTokenAccountInterfaceInstruction, +} from '@lightprotocol/compressed-token'; + +const wrapTokens = async ( + fromAddress: string, + tokenMintAddress: string, + amount: number, + decimals: number = 9 +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + const privy = new PrivyClient({ + appId: process.env.PRIVY_APP_ID!, + appSecret: process.env.PRIVY_APP_SECRET!, + }); + + const fromPubkey = new PublicKey(fromAddress); + const mintPubkey = new PublicKey(tokenMintAddress); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Verify SPL ATA balance + const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, TOKEN_2022_PROGRAM_ID); + const ataAccount = await getAccount(connection, splAta, undefined, TOKEN_2022_PROGRAM_ID); + if (ataAccount.amount < BigInt(tokenAmount)) { + throw new Error('Insufficient SPL balance'); + } + + // Get SPL interface info for the mint + const splInterfaceInfos = await getSplInterfaceInfos(connection, mintPubkey); + const splInterfaceInfo = splInterfaceInfos.find( + (info) => info.isInitialized, + ); + if (!splInterfaceInfo) throw new Error('No SPL interface found for this mint'); + + // Derive light-token ATA + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); + + // Build transaction + const transaction = new Transaction(); + transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 200_000})); + + // Ensure light-token ATA exists (idempotent create) + transaction.add( + createAssociatedTokenAccountInterfaceInstruction( + fromPubkey, + lightTokenAta, + fromPubkey, + mintPubkey, + CTOKEN_PROGRAM_ID, + ), + ); + + // Build wrap instruction + const wrapIx = createWrapInstruction( + splAta, + lightTokenAta, + fromPubkey, + mintPubkey, + tokenAmount, + splInterfaceInfo, + decimals, + fromPubkey, + ); + transaction.add(wrapIx); + + const {blockhash} = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = fromPubkey; + + // Sign with Privy + const signResult = await privy.wallets().solana().signTransaction(process.env.TREASURY_WALLET_ID!, { + transaction: transaction.serialize({requireAllSignatures: false}), + authorization_context: { + authorization_private_keys: [process.env.TREASURY_AUTHORIZATION_KEY!] + } + }); + const signedTx = (signResult as any).signed_transaction; + if (!signedTx) { + throw new Error('Privy returned invalid response: ' + JSON.stringify(signResult)); + } + const signedTransaction = Buffer.from(signedTx, 'base64'); + + // Send transaction + const signature = await connection.sendRawTransaction(signedTransaction, { + skipPreflight: false, + preflightCommitment: 'confirmed' + }); + await connection.confirmTransaction(signature, 'confirmed'); + + return signature; +}; + +export default wrapTokens; + +// --- main --- +import {TREASURY_WALLET_ADDRESS, TEST_MINT, DEFAULT_AMOUNT, DEFAULT_DECIMALS} from './config.js'; +wrapTokens( + TREASURY_WALLET_ADDRESS, + TEST_MINT, + parseFloat(DEFAULT_AMOUNT), + parseInt(DEFAULT_DECIMALS), +) + .then((result) => console.log('Wrap signature:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/tsconfig.json b/privy/nodejs-privy-light-token/tsconfig.json new file mode 100644 index 0000000..36b5ba4 --- /dev/null +++ b/privy/nodejs-privy-light-token/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/privy/react-privy-light-token/.env.example b/privy/react-privy-light-token/.env.example new file mode 100644 index 0000000..da8d1d2 --- /dev/null +++ b/privy/react-privy-light-token/.env.example @@ -0,0 +1,2 @@ +VITE_PRIVY_APP_ID= +VITE_HELIUS_RPC_URL= diff --git a/privy/react-privy-light-token/index.html b/privy/react-privy-light-token/index.html new file mode 100644 index 0000000..89231aa --- /dev/null +++ b/privy/react-privy-light-token/index.html @@ -0,0 +1,13 @@ + + + + + + + React Privy Light Token + + +
+ + + diff --git a/privy/react-privy-light-token/package.json b/privy/react-privy-light-token/package.json new file mode 100644 index 0000000..8a8a6a5 --- /dev/null +++ b/privy/react-privy-light-token/package.json @@ -0,0 +1,33 @@ +{ + "name": "react-privy-light-token", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "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", + "@types/react": "^19.1.1", + "@types/react-dom": "^19.1.1", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.1.18", + "typescript": "^5.8.3", + "vite": "^6.0.11", + "vite-plugin-node-polyfills": "^0.25.0" + } +} diff --git a/privy/react-privy-light-token/src/App.tsx b/privy/react-privy-light-token/src/App.tsx new file mode 100644 index 0000000..2fef25a --- /dev/null +++ b/privy/react-privy-light-token/src/App.tsx @@ -0,0 +1,129 @@ +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 { 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-privy-light-token/src/components/reusables/CopyButton.tsx b/privy/react-privy-light-token/src/components/reusables/CopyButton.tsx new file mode 100644 index 0000000..71258b3 --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/components/reusables/Section.tsx b/privy/react-privy-light-token/src/components/reusables/Section.tsx new file mode 100644 index 0000000..2726631 --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/components/sections/TransactionStatus.tsx b/privy/react-privy-light-token/src/components/sections/TransactionStatus.tsx new file mode 100644 index 0000000..709cdc2 --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/components/sections/TransferForm.tsx b/privy/react-privy-light-token/src/components/sections/TransferForm.tsx new file mode 100644 index 0000000..d5d332d --- /dev/null +++ b/privy/react-privy-light-token/src/components/sections/TransferForm.tsx @@ -0,0 +1,244 @@ +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 { useWrap } from '../../hooks/useWrap'; +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; +} + +export default function TransferForm({ + selectedWallet, + wallets, + onWalletChange, + selectedMint, + onMintChange, + balances, + isLoadingBalances, + onTransferSuccess, + onTransferError, +}: TransferFormProps) { + const { signTransaction } = useSignTransaction(); + const { transfer } = useTransfer(); + const { wrap } = useWrap(); + + const [recipientAddress, setRecipientAddress] = useState(''); + const [amount, setAmount] = useState('1'); + const [isLoading, setIsLoading] = useState(false); + + const handleTransfer = async () => { + if (!selectedWallet) { + alert('Please select a wallet'); + return; + } + + if (!selectedMint) { + alert('Please select a token'); + return; + } + + if (!recipientAddress) { + alert('Please enter a recipient address'); + 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) { + alert('Selected wallet not found'); + return; + } + + setIsLoading(true); + + try { + const selectedToken = balances.find(b => b.mint === selectedMint); + if (!selectedToken) { + alert('Selected token not found in balances'); + return; + } + + const { isNative, isLightToken, decimals } = selectedToken; + let signature: string; + + // Transaction routing based on token state + if (isNative) { + // SOL: display only, no transfer action for light-token + alert('SOL transfers are not supported in light-token mode'); + return; + } else if (isLightToken) { + // Light-token ATA: transfer via createTransferInterfaceInstruction + signature = await transfer({ + params: { ownerPublicKey: selectedWallet, mint: selectedMint, toAddress: recipientAddress, amount: amountNum, decimals }, + wallet, + signTransaction, + }); + } else { + // Regular SPL: wrap to own light-token ATA + signature = await wrap({ + params: { ownerPublicKey: selectedWallet, mint: selectedMint, amount: amountNum, 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); + + 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" + /> +
+ + {selectedBalance && BigInt(selectedBalance.amount) === 0n ? ( + + ) : ( + + )} +
+ ); +} diff --git a/privy/react-privy-light-token/src/components/sections/WalletInfo.tsx b/privy/react-privy-light-token/src/components/sections/WalletInfo.tsx new file mode 100644 index 0000000..89e1587 --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/components/ui/Header.tsx b/privy/react-privy-light-token/src/components/ui/Header.tsx new file mode 100644 index 0000000..0ea9234 --- /dev/null +++ b/privy/react-privy-light-token/src/components/ui/Header.tsx @@ -0,0 +1,30 @@ +export function Header() { + return ( +
+
+
+ Light Token + + Privy +
+ +
+
+ ); +} diff --git a/privy/react-privy-light-token/src/hooks/index.ts b/privy/react-privy-light-token/src/hooks/index.ts new file mode 100644 index 0000000..038828e --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/hooks/useLightTokenBalances.ts b/privy/react-privy-light-token/src/hooks/useLightTokenBalances.ts new file mode 100644 index 0000000..c4c6060 --- /dev/null +++ b/privy/react-privy-light-token/src/hooks/useLightTokenBalances.ts @@ -0,0 +1,133 @@ +import { useState, useCallback } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import { createRpc } from '@lightprotocol/stateless.js'; + +export interface TokenBalance { + mint: string; + amount: string; + decimals: number; + isLightToken: boolean; + isNative: boolean; +} + +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); + const allBalances: TokenBalance[] = []; + + // Get regular SOL balance (display only, no transfer action) + try { + const solBalance = await rpc.getBalance(owner); + allBalances.push({ + mint: 'So11111111111111111111111111111111111111112', + amount: solBalance.toString(), + decimals: 9, + isLightToken: false, + isNative: true, + }); + } catch { + // Failed to get SOL balance + } + + // Get regular SPL token accounts (Token Program) + try { + const tokenAccounts = await rpc.getTokenAccountsByOwner(owner, { + programId: new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + }); + + for (const { account } of tokenAccounts.value) { + const data = account.data; + if (data instanceof Buffer || (typeof data === 'object' && 'length' in data)) { + const dataBuffer = Buffer.from(data as Uint8Array); + if (dataBuffer.length >= 72) { + const mint = new PublicKey(dataBuffer.subarray(0, 32)); + const amount = dataBuffer.readBigUInt64LE(64); + + try { + const mintInfo = await rpc.getAccountInfo(mint); + const decimals = mintInfo?.data ? (mintInfo.data as Buffer)[44] || 9 : 9; + + allBalances.push({ + mint: mint.toBase58(), + amount: amount.toString(), + decimals, + isLightToken: false, + isNative: false, + }); + } catch { + allBalances.push({ + mint: mint.toBase58(), + amount: amount.toString(), + decimals: 9, + isLightToken: false, + isNative: false, + }); + } + } + } + } + } catch { + // Failed to get SPL token accounts + } + + // Get light-token ATAs (Token-2022 program accounts) + try { + const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_2022_PROGRAM_ID, + }); + + for (const { account } of t22Accounts.value) { + const data = account.data; + if (data instanceof Buffer || (typeof data === 'object' && 'length' in data)) { + const dataBuffer = Buffer.from(data as Uint8Array); + if (dataBuffer.length >= 72) { + const mint = new PublicKey(dataBuffer.subarray(0, 32)); + const amount = dataBuffer.readBigUInt64LE(64); + + try { + const mintInfo = await rpc.getAccountInfo(mint); + const decimals = mintInfo?.data ? (mintInfo.data as Buffer)[44] || 9 : 9; + + allBalances.push({ + mint: mint.toBase58(), + amount: amount.toString(), + decimals, + isLightToken: true, + isNative: false, + }); + } catch { + allBalances.push({ + mint: mint.toBase58(), + amount: amount.toString(), + decimals: 9, + isLightToken: true, + isNative: false, + }); + } + } + } + } + } catch { + // Failed to get T22 token accounts + } + + setBalances(allBalances); + } catch (error) { + console.error('Failed to fetch balances:', error); + setBalances([]); + } finally { + setIsLoading(false); + } + }, []); + + return { balances, isLoading, fetchBalances }; +} diff --git a/privy/react-privy-light-token/src/hooks/useTransactionHistory.ts b/privy/react-privy-light-token/src/hooks/useTransactionHistory.ts new file mode 100644 index 0000000..1f3a447 --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/hooks/useTransfer.ts b/privy/react-privy-light-token/src/hooks/useTransfer.ts new file mode 100644 index 0000000..4156d63 --- /dev/null +++ b/privy/react-privy-light-token/src/hooks/useTransfer.ts @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { PublicKey, Transaction } from '@solana/web3.js'; +import { + createTransferInterfaceInstruction, + getAssociatedTokenAddressInterface, +} from '@lightprotocol/compressed-token'; +import { createRpc } 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 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)); + + // Derive light-token ATAs (no account lookups or proofs needed) + const sourceAta = getAssociatedTokenAddressInterface(mintPubkey, owner); + const destAta = getAssociatedTokenAddressInterface(mintPubkey, recipient); + + // Build transfer instruction + const transferIx = createTransferInterfaceInstruction( + sourceAta, + destAta, + owner, + tokenAmount, + ); + + // Build transaction + const { blockhash } = await rpc.getLatestBlockhash(); + const transaction = new Transaction(); + transaction.add(transferIx); + transaction.recentBlockhash = blockhash; + transaction.feePayer = owner; + + // Serialize unsigned transaction + const unsignedTxBuffer = transaction.serialize({ requireAllSignatures: false }); + + // Sign with Privy + const signedTx = await signTransaction({ + transaction: unsignedTxBuffer, + wallet, + chain: 'solana:devnet', + }); + + // Send transaction + const signedTxBuffer = Buffer.from(signedTx.signedTransaction); + const signature = await rpc.sendRawTransaction(signedTxBuffer, { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { transfer, isLoading }; +} diff --git a/privy/react-privy-light-token/src/hooks/useUnwrap.ts b/privy/react-privy-light-token/src/hooks/useUnwrap.ts new file mode 100644 index 0000000..51efc51 --- /dev/null +++ b/privy/react-privy-light-token/src/hooks/useUnwrap.ts @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { PublicKey, Transaction, ComputeBudgetProgram } from '@solana/web3.js'; +import { + getAssociatedTokenAddressSync, + createAssociatedTokenAccountInstruction, + getAccount, + TokenAccountNotFoundError, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { + getAssociatedTokenAddressInterface, + getSplInterfaceInfos, +} from '@lightprotocol/compressed-token'; +import { createUnwrapInstruction } 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'; + +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))); + + // Source: light-token ATA + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, owner); + + // Destination: SPL T22 ATA + const splAta = getAssociatedTokenAddressSync(mintPubkey, owner, false, TOKEN_2022_PROGRAM_ID); + + // Get SPL interface info + 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'); + + // Build transaction + const { blockhash } = await rpc.getLatestBlockhash(); + const transaction = new Transaction(); + transaction.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 })); + + // Create SPL T22 ATA if needed + try { + await getAccount(rpc, splAta, undefined, TOKEN_2022_PROGRAM_ID); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + transaction.add( + createAssociatedTokenAccountInstruction( + owner, + splAta, + owner, + mintPubkey, + TOKEN_2022_PROGRAM_ID, + ), + ); + } else { + throw e; + } + } + + // Build unwrap instruction + const unwrapIx = createUnwrapInstruction( + lightTokenAta, + splAta, + owner, + mintPubkey, + tokenAmount, + splInterfaceInfo, + decimals, + owner, + ); + transaction.add(unwrapIx); + transaction.recentBlockhash = blockhash; + transaction.feePayer = owner; + + // Serialize unsigned transaction + const unsignedTxBuffer = transaction.serialize({ requireAllSignatures: false }); + + // Sign with Privy + const signedTx = await signTransaction({ + transaction: unsignedTxBuffer, + wallet, + chain: 'solana:devnet', + }); + + // Send transaction + const signedTxBuffer = Buffer.from(signedTx.signedTransaction); + const signature = await rpc.sendRawTransaction(signedTxBuffer, { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { unwrap, isLoading }; +} diff --git a/privy/react-privy-light-token/src/hooks/useWrap.ts b/privy/react-privy-light-token/src/hooks/useWrap.ts new file mode 100644 index 0000000..2c86db2 --- /dev/null +++ b/privy/react-privy-light-token/src/hooks/useWrap.ts @@ -0,0 +1,117 @@ +import { useState } from 'react'; +import { PublicKey, Transaction, ComputeBudgetProgram } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync, getAccount } from '@solana/spl-token'; +import { + createWrapInstruction, + getAssociatedTokenAddressInterface, + getSplInterfaceInfos, +} from '@lightprotocol/compressed-token'; +import { createAssociatedTokenAccountInterfaceInstruction } from '@lightprotocol/compressed-token'; +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))); + + // Verify SPL ATA balance + const splAta = getAssociatedTokenAddressSync(mintPubkey, owner); + const ataAccount = await getAccount(rpc, splAta); + if (ataAccount.amount < BigInt(tokenAmount)) { + throw new Error('Insufficient SPL balance'); + } + + // Get SPL interface info for the mint + 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'); + + // Derive light-token ATA + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, owner); + + // Build transaction + const { blockhash } = await rpc.getLatestBlockhash(); + const transaction = new Transaction(); + transaction.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 })); + + // Ensure light-token ATA exists (idempotent create) + transaction.add( + createAssociatedTokenAccountInterfaceInstruction( + owner, + lightTokenAta, + owner, + mintPubkey, + CTOKEN_PROGRAM_ID, + ), + ); + + // Build wrap instruction + const wrapIx = createWrapInstruction( + splAta, + lightTokenAta, + owner, + mintPubkey, + tokenAmount, + splInterfaceInfo, + decimals, + owner, + ); + transaction.add(wrapIx); + transaction.recentBlockhash = blockhash; + transaction.feePayer = owner; + + // Serialize unsigned transaction + const unsignedTxBuffer = transaction.serialize({ requireAllSignatures: false }); + + // Sign with Privy + const signedTx = await signTransaction({ + transaction: unsignedTxBuffer, + wallet, + chain: 'solana:devnet', + }); + + // Send transaction + const signedTxBuffer = Buffer.from(signedTx.signedTransaction); + const signature = await rpc.sendRawTransaction(signedTxBuffer, { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { wrap, isLoading }; +} diff --git a/privy/react-privy-light-token/src/index.css b/privy/react-privy-light-token/src/index.css new file mode 100644 index 0000000..f9e38ba --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/main.tsx b/privy/react-privy-light-token/src/main.tsx new file mode 100644 index 0000000..000db4e --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/src/vite-env.d.ts b/privy/react-privy-light-token/src/vite-env.d.ts new file mode 100644 index 0000000..5cea1e9 --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/tsconfig.json b/privy/react-privy-light-token/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/tsconfig.node.json b/privy/react-privy-light-token/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/privy/react-privy-light-token/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-privy-light-token/vite.config.ts b/privy/react-privy-light-token/vite.config.ts new file mode 100644 index 0000000..604d69a --- /dev/null +++ b/privy/react-privy-light-token/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +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'] })], +}); From 9b0febd559010c93f86556dbbfdf692d627ceaab Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Wed, 11 Feb 2026 16:28:28 +0000 Subject: [PATCH 02/15] privy nodejs --- CLAUDE.md | 145 ++++++++++++++++++ package.json | 4 +- privy/README.md | 7 + privy/nodejs-privy-light-token/README.md | 70 --------- .../src/helpers/mint-light-token.ts | 102 ------------ .../.env.example | 0 .../.gitignore | 0 privy/nodejs/README.md | 145 ++++++++++++++++++ .../package.json | 0 .../src/balances.ts | 5 +- .../src/config.ts | 0 .../src/get-transaction-history.ts | 0 .../src/helpers/decompress.ts | 0 privy/nodejs/src/helpers/mint-light-token.ts | 110 +++++++++++++ .../src/helpers/mint-spl.ts | 16 +- .../src/helpers/register-spl-interface.ts | 7 +- .../src/load.ts | 18 +-- .../src/transfer.ts | 102 +++++------- .../src/unwrap.ts | 23 +-- .../src/wrap.ts | 23 +-- .../tsconfig.json | 0 21 files changed, 493 insertions(+), 284 deletions(-) create mode 100644 CLAUDE.md create mode 100644 privy/README.md delete mode 100644 privy/nodejs-privy-light-token/README.md delete mode 100644 privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts rename privy/{nodejs-privy-light-token => nodejs}/.env.example (100%) rename privy/{nodejs-privy-light-token => nodejs}/.gitignore (100%) create mode 100644 privy/nodejs/README.md rename privy/{nodejs-privy-light-token => nodejs}/package.json (100%) rename privy/{nodejs-privy-light-token => nodejs}/src/balances.ts (96%) rename privy/{nodejs-privy-light-token => nodejs}/src/config.ts (100%) rename privy/{nodejs-privy-light-token => nodejs}/src/get-transaction-history.ts (100%) rename privy/{nodejs-privy-light-token => nodejs}/src/helpers/decompress.ts (100%) create mode 100644 privy/nodejs/src/helpers/mint-light-token.ts rename privy/{nodejs-privy-light-token => nodejs}/src/helpers/mint-spl.ts (84%) rename privy/{nodejs-privy-light-token => nodejs}/src/helpers/register-spl-interface.ts (73%) rename privy/{nodejs-privy-light-token => nodejs}/src/load.ts (87%) rename privy/{nodejs-privy-light-token => nodejs}/src/transfer.ts (59%) rename privy/{nodejs-privy-light-token => nodejs}/src/unwrap.ts (90%) rename privy/{nodejs-privy-light-token => nodejs}/src/wrap.ts (88%) rename privy/{nodejs-privy-light-token => nodejs}/tsconfig.json (100%) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d73154 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ +# 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 +``` + +### 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` and `toolkits/payments-and-wallets`. 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 + +### 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`. + +### Key dependencies + +- `@lightprotocol/compressed-token` beta — TypeScript token client (also exports `/unified` subpath for payments toolkit) +- `@lightprotocol/stateless.js` beta — TypeScript RPC client (`createRpc`, `buildAndSignTx`, `sendAndConfirmTx`) +- `@solana/web3.js` 1.98.x — Solana web3 (v1, not v2) +- `@solana/spl-token` 0.4.x — SPL token operations (used for T22 interop) +- `light-sdk` 0.19.x — Rust core SDK +- `light-token` 0.4.x — Rust token operations +- Anchor 0.31.1, Solana SDK 2.2, Rust 1.90.0 + +## Environment setup + +Copy `.env.example` to `.env` and set `API_KEY` for devnet/mainnet RPC access. Localnet uses default endpoints (no env needed). + +## Documentation + +https://www.zkcompression.com/light-token/welcome diff --git a/package.json b/package.json index 841373f..57dc1db 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "private": true, "workspaces": [ "typescript-client", - "toolkits/payments-and-wallets" + "toolkits/payments-and-wallets", + "privy/react-privy-light-token", + "privy/nodejs" ], "scripts": { "toolkit:payments": "npm run -w toolkits/payments-and-wallets" diff --git a/privy/README.md b/privy/README.md new file mode 100644 index 0000000..b87cfd1 --- /dev/null +++ b/privy/README.md @@ -0,0 +1,7 @@ +# Privy + Light Token + +Wrap, transfer, and unwrap light-tokens signed with Privy server wallets. + +- **[Node.js](nodejs/)** — Server-side scripts using `@privy-io/node` + +Learn more [about Light Token here](https://www.zkcompression.com/light-token/welcome). diff --git a/privy/nodejs-privy-light-token/README.md b/privy/nodejs-privy-light-token/README.md deleted file mode 100644 index 000149d..0000000 --- a/privy/nodejs-privy-light-token/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Privy + Light Token (Node.js) - -Server-side light-token operations using [Privy](https://privy.io) server wallets on Solana devnet. - -Privy server wallets sign transactions without exposing private keys. This example demonstrates transfers, balance queries, wrapping/unwrapping, and compressed token loading — all signed via the Privy API. - -## Prerequisites - -- Node.js 18+ -- [Solana CLI](https://docs.solana.com/cli/install-solana-cli-tools) with a keypair at `~/.config/solana/id.json` (for helper scripts) -- Privy account with a server wallet configured -- [Helius](https://helius.dev) API key for devnet RPC - -## Setup - -```bash -npm install -cp .env.example .env -# Fill in .env with your credentials -``` - -### Environment variables - -| Variable | Description | -|----------|-------------| -| `PRIVY_APP_ID` | Privy application ID | -| `PRIVY_APP_SECRET` | Privy application secret | -| `TREASURY_WALLET_ID` | Privy server wallet ID | -| `TREASURY_WALLET_ADDRESS` | Solana address of the server wallet | -| `TREASURY_AUTHORIZATION_KEY` | EC private key for ECDSA authorization | -| `HELIUS_RPC_URL` | Helius devnet RPC endpoint | -| `TEST_MINT` | Light-token mint address to use | - -## Scripts - -### Core operations (Privy-signed) - -| Script | Command | Description | -|--------|---------|-------------| -| `transfer` | `npm run transfer` | Transfer light-token ATA to ATA. Pass `--type=t22` to unwrap to SPL T22 instead. Auto-loads cold balance before transfer. | -| `load` | `npm run load` | Load compressed tokens (cold) into light-token ATA (hot). | -| `wrap` | `npm run wrap` | Wrap SPL T22 tokens into light-token ATA. | -| `unwrap` | `npm run unwrap` | Unwrap light-token ATA to SPL T22 ATA. | -| `balances` | `npm run balances` | Query unified balance + breakdown (hot, cold, T22, SOL). | -| `history` | `npm run history` | Get transaction history for the treasury wallet. | - -### Helper scripts (filesystem wallet) - -These use the local Solana keypair at `~/.config/solana/id.json`, not Privy. - -| Script | Command | Description | -|--------|---------|-------------| -| `mint:light-token` | `npm run mint:light-token` | Create a light-token mint and mint tokens. | -| `mint:spl` | `npm run mint:spl` | Mint SPL tokens for an existing SPL mint. | -| `register:spl-interface` | `npm run register:spl-interface` | Register SPL interface (required for wrap/unwrap). | -| `decompress` | `npm run decompress` | Decompress light-token ATA to SPL T22 ATA. | - -## Flows - -### Transfer flow - -``` -mint:light-token → load (if cold balance) → transfer → history → balances -``` - -### Wrap/unwrap flow - -``` -mint:light-token → register:spl-interface → decompress → wrap → unwrap → balances -``` diff --git a/privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts b/privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts deleted file mode 100644 index b4a13df..0000000 --- a/privy/nodejs-privy-light-token/src/helpers/mint-light-token.ts +++ /dev/null @@ -1,102 +0,0 @@ -import 'dotenv/config'; -import {Keypair, PublicKey, Transaction, ComputeBudgetProgram, sendAndConfirmTransaction} from '@solana/web3.js'; -import {createRpc, getBatchAddressTreeInfo, selectStateTreeInfo, CTOKEN_PROGRAM_ID} from '@lightprotocol/stateless.js'; -import { - createMintInstruction, - createAtaInterface, - mintToInterface, - getAssociatedTokenAddressInterface, -} from '@lightprotocol/compressed-token'; -import {homedir} from 'os'; -import {readFileSync} from 'fs'; - -const COMPRESSED_MINT_SEED = Buffer.from('compressed_mint'); - -function findMintAddress(mintSigner: PublicKey): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [COMPRESSED_MINT_SEED, mintSigner.toBuffer()], - CTOKEN_PROGRAM_ID, - ); -} - -const createLightTokenMint = async ( - decimals: number, - initialAmount: number, - recipientAddress: string -) => { - const connection = createRpc(process.env.HELIUS_RPC_URL!); - - // Load filesystem wallet (not Privy — helper script) - const payer = Keypair.fromSecretKey( - new Uint8Array( - JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) - ) - ); - - const mintSigner = Keypair.generate(); - const addressTreeInfo = getBatchAddressTreeInfo(); - const stateTreeInfo = selectStateTreeInfo(await connection.getStateTreeInfos()); - const [mintPda] = findMintAddress(mintSigner.publicKey); - - const validityProof = await connection.getValidityProofV2( - [], - [{address: mintPda.toBytes(), treeInfo: addressTreeInfo}], - ); - - const ix = createMintInstruction( - mintSigner.publicKey, - decimals, - payer.publicKey, - null, - payer.publicKey, - validityProof, - addressTreeInfo, - stateTreeInfo, - ); - - const transaction = new Transaction().add( - ComputeBudgetProgram.setComputeUnitLimit({units: 1_000_000}), - ix, - ); - - const createSignature = await sendAndConfirmTransaction( - connection, - transaction, - [payer, mintSigner], - {commitment: 'confirmed'} - ); - - console.log('Mint:', mintPda.toBase58()); - console.log('Create signature:', createSignature); - - // If initialAmount > 0, create ATA and mint tokens - if (initialAmount > 0) { - const recipient = new PublicKey(recipientAddress); - await createAtaInterface(connection, payer, mintPda, recipient); - const recipientAta = getAssociatedTokenAddressInterface(mintPda, recipient); - - await mintToInterface( - connection, - payer, - mintPda, - recipientAta, - payer, - initialAmount * Math.pow(10, decimals), - ); - - console.log('Minted', initialAmount, 'tokens to', recipientAddress); - } - - return { - mintAddress: mintPda.toBase58(), - signature: createSignature, - }; -}; - -export default createLightTokenMint; - -// --- main --- -import {TREASURY_WALLET_ADDRESS} from '../config.js'; -createLightTokenMint(9, 100, TREASURY_WALLET_ADDRESS) - .then((result) => console.log('Mint result:', result)) - .catch(console.error); diff --git a/privy/nodejs-privy-light-token/.env.example b/privy/nodejs/.env.example similarity index 100% rename from privy/nodejs-privy-light-token/.env.example rename to privy/nodejs/.env.example diff --git a/privy/nodejs-privy-light-token/.gitignore b/privy/nodejs/.gitignore similarity index 100% rename from privy/nodejs-privy-light-token/.gitignore rename to privy/nodejs/.gitignore diff --git a/privy/nodejs/README.md b/privy/nodejs/README.md new file mode 100644 index 0000000..2f1df7b --- /dev/null +++ b/privy/nodejs/README.md @@ -0,0 +1,145 @@ +# Privy + Light Token (Node.js) + +Privy handles user authentication and wallet management. You build transactions with light-token instructions and Privy signs them server-side: + +1. Authenticate with Privy +2. Build unsigned transaction +3. Sign transaction using Privy's wallet API +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**](#operations) | `transferChecked()` | `createTransferInterfaceInstruction()` | +| [**Wrap**](#operations) | N/A | `createWrapInstruction()` | +| [**Unwrap**](#operations) | N/A | `createUnwrapInstruction()` | +| [**Load**](#operations) | N/A | `createLoadAtaInstructionsFromInterface()` | +| [**Get balance**](#operations) | `getAccount()` | `getAtaInterface()` | +| [**Transaction history**](#operations) | `getSignaturesForAddress()` | `getSignaturesForOwnerInterface()` | + +### Source files + +- **[transfer.ts](src/transfer.ts)** — Transfer light-tokens between wallets. Auto-loads cold balance before sending. +- **[wrap.ts](src/wrap.ts)** — Wrap SPL or T22 tokens into light-token ATA. +- **[unwrap.ts](src/unwrap.ts)** — Unwrap light-token ATA back to SPL or T22. +- **[load.ts](src/load.ts)** — Consolidate cold (compressed) and SPL/T22 balances into the light-token ATA. +- **[balances.ts](src/balances.ts)** — Query balance breakdown: hot, cold, SPL/T22, and SOL. +- **[get-transaction-history.ts](src/get-transaction-history.ts)** — Fetch transaction history for light-token operations. + +> Light Token is currently deployed on **devnet**. The interface PDA pattern described here applies to mainnet. + +## Before you start + +### 1. 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 +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 ATA. Set `TEST_MINT` in `.env` to the USDC mint address. + +### 2. Recipients need balance visibility + +Your app must query balances through a ZK compression-compatible RPC (Helius or Triton): + +```typescript +import { createRpc } from "@lightprotocol/stateless.js"; +import { getAtaInterface } from "@lightprotocol/compressed-token/unified"; + +const rpc = createRpc(process.env.HELIUS_RPC_URL); + +// Hot balance (light-token ATA) +const { parsed, isCold } = await getAtaInterface(rpc, ata, owner, mint); + +// Cold balance (compressed tokens not yet loaded into ATA) +const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner, { mint }); +``` + +See `src/balances.ts` for the full pattern that queries hot, cold, and SPL/T22 balances. + +## Setup + +```bash +npm install +cp .env.example .env +# Fill in your credentials +``` + +### Environment variables + +| Variable | Description | +| -------- | ----------- | +| `PRIVY_APP_ID` | From the [Privy console](https://console.privy.io). | +| `PRIVY_APP_SECRET` | Server-side secret from Privy console. | +| `TREASURY_WALLET_ID` | UUID of your Privy server wallet. | +| `TREASURY_WALLET_ADDRESS` | Solana public key of that wallet. | +| `TREASURY_AUTHORIZATION_KEY` | EC private key for ECDSA transaction authorization. | +| `HELIUS_RPC_URL` | Helius RPC endpoint (e.g. `https://devnet.helius-rpc.com?api-key=...`). Required for ZK compression indexing. | +| `TEST_MINT` | Mint address to use in example scripts. | + +## Operations + +### App operations (Privy-signed) + +These are the operations your app calls at runtime. Transactions are signed server-side via the Privy API. + +| Command | What it does | +| ------- | ----------- | +| `npm run transfer` | Transfer light-tokens between wallets. Auto-loads cold balance before sending. | +| `npm run wrap` | Wrap SPL or T22 tokens into light-token ATA. Auto-detects token program from the mint's interface PDA. | +| `npm run unwrap` | Unwrap light-token ATA back to SPL or T22. Auto-detects token program. | +| `npm run load` | Consolidate cold (compressed) and SPL/T22 balances into the light-token ATA. | +| `npm run balances` | Query balance breakdown: hot, cold, SPL/T22, and SOL. | +| `npm run history` | Fetch transaction history for light-token interface operations. | + +### Setup helpers (local keypair) + +Run once to create test mints and fund balances. These use the Solana CLI keypair at `~/.config/solana/id.json`, not Privy. + +| Command | What it does | +| ------- | ----------- | +| `npm run mint:light-token` | Create an SPL or T22 mint with interface PDA, mint tokens, wrap, and transfer to treasury. | +| `npm run mint:spl` | 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. | +| `npm run decompress` | Decompress light-token ATA to a T22 ATA. | + +## Quick start + +Run these in order to see the full flow on devnet: + +```bash +# 1. Create a test mint with interface PDA + fund the treasury wallet +npm run mint:light-token + +# 2. Check balances — should show 100 tokens in hot balance +npm run balances + +# 3. Transfer light-tokens to the default recipient +npm run transfer + +# 4. Unwrap light-tokens back to SPL/T22 +npm run unwrap + +# 5. Wrap SPL/T22 tokens back into light-token ATA +npm run wrap +``` diff --git a/privy/nodejs-privy-light-token/package.json b/privy/nodejs/package.json similarity index 100% rename from privy/nodejs-privy-light-token/package.json rename to privy/nodejs/package.json diff --git a/privy/nodejs-privy-light-token/src/balances.ts b/privy/nodejs/src/balances.ts similarity index 96% rename from privy/nodejs-privy-light-token/src/balances.ts rename to privy/nodejs/src/balances.ts index 1b82818..fd6d1c6 100644 --- a/privy/nodejs-privy-light-token/src/balances.ts +++ b/privy/nodejs/src/balances.ts @@ -6,8 +6,6 @@ import { getAtaInterface, getAssociatedTokenAddressInterface, } from '@lightprotocol/compressed-token/unified'; -import {HELIUS_RPC_URL, TREASURY_WALLET_ADDRESS, TEST_MINT} from './config.js'; - interface BalanceBreakdown { sol: number; token?: { @@ -25,7 +23,7 @@ export async function getBalances( ownerAddress: string, mintAddress?: string, ): Promise { - const rpc = createRpc(HELIUS_RPC_URL); + const rpc = createRpc(process.env.HELIUS_RPC_URL!); const owner = new PublicKey(ownerAddress); // SOL balance @@ -122,6 +120,7 @@ function toUiAmount(raw: bigint | {toNumber: () => number}, decimals: number): n export default getBalances; // --- main --- +import {TREASURY_WALLET_ADDRESS, TEST_MINT} from './config.js'; getBalances(TREASURY_WALLET_ADDRESS, TEST_MINT) .then((b) => { console.log(`SOL: ${b.sol}`); diff --git a/privy/nodejs-privy-light-token/src/config.ts b/privy/nodejs/src/config.ts similarity index 100% rename from privy/nodejs-privy-light-token/src/config.ts rename to privy/nodejs/src/config.ts diff --git a/privy/nodejs-privy-light-token/src/get-transaction-history.ts b/privy/nodejs/src/get-transaction-history.ts similarity index 100% rename from privy/nodejs-privy-light-token/src/get-transaction-history.ts rename to privy/nodejs/src/get-transaction-history.ts diff --git a/privy/nodejs-privy-light-token/src/helpers/decompress.ts b/privy/nodejs/src/helpers/decompress.ts similarity index 100% rename from privy/nodejs-privy-light-token/src/helpers/decompress.ts rename to privy/nodejs/src/helpers/decompress.ts diff --git a/privy/nodejs/src/helpers/mint-light-token.ts b/privy/nodejs/src/helpers/mint-light-token.ts new file mode 100644 index 0000000..25137bf --- /dev/null +++ b/privy/nodejs/src/helpers/mint-light-token.ts @@ -0,0 +1,110 @@ +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_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 (not Privy — helper script) + 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(initialAmount * Math.pow(10, decimals)); + + // 1. Mint SPL/T22 tokens to payer's ATA (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 ATA + 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 ATA'); + + // 3. Transfer payer's light ATA → recipient's light ATA + 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 --- +import {TREASURY_WALLET_ADDRESS} from '../config.js'; +createLightTokenMint(9, 100, TREASURY_WALLET_ADDRESS) + .then((result) => console.log('Mint result:', result)) + .catch(console.error); diff --git a/privy/nodejs-privy-light-token/src/helpers/mint-spl.ts b/privy/nodejs/src/helpers/mint-spl.ts similarity index 84% rename from privy/nodejs-privy-light-token/src/helpers/mint-spl.ts rename to privy/nodejs/src/helpers/mint-spl.ts index f14f622..e5625ae 100644 --- a/privy/nodejs-privy-light-token/src/helpers/mint-spl.ts +++ b/privy/nodejs/src/helpers/mint-spl.ts @@ -2,6 +2,7 @@ import 'dotenv/config'; import {Keypair, PublicKey, Transaction, ComputeBudgetProgram, sendAndConfirmTransaction} from '@solana/web3.js'; import {createRpc} from '@lightprotocol/stateless.js'; import { + TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, createAssociatedTokenAccountInstruction, createMintToInstruction, @@ -15,7 +16,8 @@ const mintSplTokens = async ( mintAddress: string, recipientAddress: string, amount: number, - decimals: number + decimals: number, + tokenProgramId: PublicKey = TOKEN_PROGRAM_ID, ) => { const connection = createRpc(process.env.HELIUS_RPC_URL!); @@ -30,8 +32,8 @@ const mintSplTokens = async ( const recipient = new PublicKey(recipientAddress); const tokenAmount = BigInt(amount * Math.pow(10, decimals)); - // Get recipient ATA - const recipientAta = getAssociatedTokenAddressSync(mint, recipient); + // Derive recipient ATA for the given token program (SPL or T22) + const recipientAta = getAssociatedTokenAddressSync(mint, recipient, false, tokenProgramId); // Build transaction const transaction = new Transaction(); @@ -39,11 +41,11 @@ const mintSplTokens = async ( // Create ATA if it doesn't exist try { - await getAccount(connection, recipientAta); + await getAccount(connection, recipientAta, undefined, tokenProgramId); } catch (e) { if (e instanceof TokenAccountNotFoundError) { transaction.add( - createAssociatedTokenAccountInstruction(payer.publicKey, recipientAta, recipient, mint) + createAssociatedTokenAccountInstruction(payer.publicKey, recipientAta, recipient, mint, tokenProgramId) ); } else { throw e; @@ -56,7 +58,9 @@ const mintSplTokens = async ( mint, recipientAta, payer.publicKey, - tokenAmount + tokenAmount, + [], + tokenProgramId, ) ); diff --git a/privy/nodejs-privy-light-token/src/helpers/register-spl-interface.ts b/privy/nodejs/src/helpers/register-spl-interface.ts similarity index 73% rename from privy/nodejs-privy-light-token/src/helpers/register-spl-interface.ts rename to privy/nodejs/src/helpers/register-spl-interface.ts index 133a283..27dfbbe 100644 --- a/privy/nodejs-privy-light-token/src/helpers/register-spl-interface.ts +++ b/privy/nodejs/src/helpers/register-spl-interface.ts @@ -5,9 +5,12 @@ import {createSplInterface} from '@lightprotocol/compressed-token'; import {homedir} from 'os'; import {readFileSync} from 'fs'; -const registerSplInterface = async (mintAddress: string) => { +// 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 (not Privy — helper script) const payer = Keypair.fromSecretKey( new Uint8Array( @@ -16,7 +19,7 @@ const registerSplInterface = async (mintAddress: string) => { ); const mint = new PublicKey(mintAddress); - const tx = await createSplInterface(connection, payer, mint); + const tx = await createSplInterface(connection, payer, mint, undefined, tokenProgramId); console.log('Mint:', mint.toBase58()); console.log('Register SPL interface signature:', tx); diff --git a/privy/nodejs-privy-light-token/src/load.ts b/privy/nodejs/src/load.ts similarity index 87% rename from privy/nodejs-privy-light-token/src/load.ts rename to privy/nodejs/src/load.ts index 59971ef..0dc24dd 100644 --- a/privy/nodejs-privy-light-token/src/load.ts +++ b/privy/nodejs/src/load.ts @@ -9,23 +9,15 @@ import { getAtaInterface, createLoadAtaInstructionsFromInterface, } from '@lightprotocol/compressed-token/unified'; -import { - TREASURY_WALLET_ID, - TREASURY_AUTHORIZATION_KEY, - PRIVY_APP_ID, - PRIVY_APP_SECRET, - HELIUS_RPC_URL, -} from './config.js'; - const loadLightTokens = async ( ownerAddress: string, mintAddress: string, ) => { - const connection = createRpc(HELIUS_RPC_URL); + const connection = createRpc(process.env.HELIUS_RPC_URL!); const privy = new PrivyClient({ - appId: PRIVY_APP_ID, - appSecret: PRIVY_APP_SECRET, + appId: process.env.PRIVY_APP_ID!, + appSecret: process.env.PRIVY_APP_SECRET!, }); const ownerPubkey = new PublicKey(ownerAddress); @@ -58,10 +50,10 @@ const loadLightTokens = async ( transaction.feePayer = ownerPubkey; // Sign with Privy - const signResult = await privy.wallets().solana().signTransaction(TREASURY_WALLET_ID, { + const signResult = await privy.wallets().solana().signTransaction(process.env.TREASURY_WALLET_ID!, { transaction: transaction.serialize({requireAllSignatures: false}), authorization_context: { - authorization_private_keys: [TREASURY_AUTHORIZATION_KEY] + authorization_private_keys: [process.env.TREASURY_AUTHORIZATION_KEY!] } }); const signedTx = (signResult as any).signed_transaction; diff --git a/privy/nodejs-privy-light-token/src/transfer.ts b/privy/nodejs/src/transfer.ts similarity index 59% rename from privy/nodejs-privy-light-token/src/transfer.ts rename to privy/nodejs/src/transfer.ts index 7933fba..c704412 100644 --- a/privy/nodejs-privy-light-token/src/transfer.ts +++ b/privy/nodejs/src/transfer.ts @@ -10,100 +10,72 @@ import { getAtaInterface, createLoadAtaInstructionsFromInterface, } from '@lightprotocol/compressed-token/unified'; -import { - TREASURY_WALLET_ID, - TREASURY_AUTHORIZATION_KEY, - PRIVY_APP_ID, - PRIVY_APP_SECRET, - HELIUS_RPC_URL, -} from './config.js'; - -const signAndSend = async ( - transaction: Transaction, - connection: ReturnType, - privy: PrivyClient, -) => { - const signResult = await privy.wallets().solana().signTransaction(TREASURY_WALLET_ID, { - transaction: transaction.serialize({requireAllSignatures: false}), - authorization_context: { - authorization_private_keys: [TREASURY_AUTHORIZATION_KEY] - } - }); - const signedTx = (signResult as any).signed_transaction; - if (!signedTx) { - throw new Error('Privy returned invalid response: ' + JSON.stringify(signResult)); - } - - const signature = await connection.sendRawTransaction(Buffer.from(signedTx, 'base64'), { - skipPreflight: false, - preflightCommitment: 'confirmed' - }); - await connection.confirmTransaction(signature, 'confirmed'); - return signature; -}; const transferLightTokens = async ( fromAddress: string, toAddress: string, tokenMintAddress: string, amount: number, - decimals: number = 9, + decimals: number = 9 ) => { - const connection = createRpc(HELIUS_RPC_URL); + const connection = createRpc(process.env.HELIUS_RPC_URL!); const privy = new PrivyClient({ - appId: PRIVY_APP_ID, - appSecret: PRIVY_APP_SECRET, + appId: process.env.PRIVY_APP_ID!, + appSecret: process.env.PRIVY_APP_SECRET!, }); + // Create public key objects const fromPubkey = new PublicKey(fromAddress); const toPubkey = new PublicKey(toAddress); const mintPubkey = new PublicKey(tokenMintAddress); + const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); + // Derive light-token ATAs const sourceAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); + const destAta = getAssociatedTokenAddressInterface(mintPubkey, toPubkey); - // Consolidate all token sources (cold compressed + SPL + T22) into light-token ATA + // Build transaction + const transaction = new Transaction(); + + // Consolidate cold + SPL + T22 into light ATA if needed try { const ataInfo = await getAtaInterface(connection, sourceAta, fromPubkey, mintPubkey); - const loadIxs = await createLoadAtaInstructionsFromInterface( - connection, - fromPubkey, - ataInfo, - undefined, - true, // wrap=true: consolidate SPL + T22 + cold into light ATA + connection, fromPubkey, ataInfo, undefined, true, ); - - if (loadIxs.length > 0) { - console.log('Consolidating token sources into light-token ATA...'); - const loadTx = new Transaction(); - loadTx.add(...loadIxs); - - const {blockhash} = await connection.getLatestBlockhash(); - loadTx.recentBlockhash = blockhash; - loadTx.feePayer = fromPubkey; - - const loadSig = await signAndSend(loadTx, connection, privy); - console.log('Load signature:', loadSig); - } + if (loadIxs.length > 0) transaction.add(...loadIxs); } catch { - // ATA doesn't exist yet — nothing to consolidate + // ATA doesn't exist yet } - // Transfer light-token ATA to light-token ATA - const destAta = getAssociatedTokenAddressInterface(mintPubkey, toPubkey); - const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); - - const transaction = new Transaction(); - transaction.add( - createTransferInterfaceInstruction(sourceAta, destAta, fromPubkey, tokenAmount), - ); + // Transfer light-token ATA → light-token ATA + transaction.add(createTransferInterfaceInstruction(sourceAta, destAta, fromPubkey, tokenAmount)); const {blockhash} = await connection.getLatestBlockhash(); transaction.recentBlockhash = blockhash; transaction.feePayer = fromPubkey; - const signature = await signAndSend(transaction, connection, privy); + // Sign with Privy + const signResult = await privy.wallets().solana().signTransaction(process.env.TREASURY_WALLET_ID!, { + transaction: transaction.serialize({requireAllSignatures: false}), + authorization_context: { + authorization_private_keys: [process.env.TREASURY_AUTHORIZATION_KEY!] + } + }); + const signedTx = (signResult as any).signed_transaction; + if (!signedTx) { + throw new Error('Privy returned invalid response: ' + JSON.stringify(signResult)); + } + const signedTransaction = Buffer.from(signedTx, 'base64'); + + // Send transaction + const signature = await connection.sendRawTransaction(signedTransaction, { + skipPreflight: false, + preflightCommitment: 'confirmed' + }); + await connection.confirmTransaction(signature, 'confirmed'); + return signature; }; diff --git a/privy/nodejs-privy-light-token/src/unwrap.ts b/privy/nodejs/src/unwrap.ts similarity index 90% rename from privy/nodejs-privy-light-token/src/unwrap.ts rename to privy/nodejs/src/unwrap.ts index 7b41881..38471cc 100644 --- a/privy/nodejs-privy-light-token/src/unwrap.ts +++ b/privy/nodejs/src/unwrap.ts @@ -7,7 +7,6 @@ import { createAssociatedTokenAccountInstruction, getAccount, TokenAccountNotFoundError, - TOKEN_2022_PROGRAM_ID, } from '@solana/spl-token'; import { getAssociatedTokenAddressInterface, @@ -32,26 +31,28 @@ const unwrapTokens = async ( const mintPubkey = new PublicKey(tokenMintAddress); const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); - // Source: light-token ATA - const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); - - // Destination: SPL T22 ATA - const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, TOKEN_2022_PROGRAM_ID); - - // Get SPL interface info + // Get SPL interface info — determines whether mint uses SPL or T22 const splInterfaceInfos = await getSplInterfaceInfos(connection, mintPubkey); const splInterfaceInfo = splInterfaceInfos.find( (info) => info.isInitialized, ); if (!splInterfaceInfo) throw new Error('No SPL interface found for this mint'); + const {tokenProgram} = splInterfaceInfo; + + // Source: light-token ATA + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); + + // Destination: SPL/T22 ATA (derived using the mint's token program) + const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, tokenProgram); + // Build transaction const transaction = new Transaction(); transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 200_000})); - // Create SPL T22 ATA if needed + // Create SPL/T22 ATA if needed try { - await getAccount(connection, splAta, undefined, TOKEN_2022_PROGRAM_ID); + await getAccount(connection, splAta, undefined, tokenProgram); } catch (e) { if (e instanceof TokenAccountNotFoundError) { transaction.add( @@ -60,7 +61,7 @@ const unwrapTokens = async ( splAta, fromPubkey, mintPubkey, - TOKEN_2022_PROGRAM_ID, + tokenProgram, ), ); } else { diff --git a/privy/nodejs-privy-light-token/src/wrap.ts b/privy/nodejs/src/wrap.ts similarity index 88% rename from privy/nodejs-privy-light-token/src/wrap.ts rename to privy/nodejs/src/wrap.ts index 9f2b1b5..ee444ca 100644 --- a/privy/nodejs-privy-light-token/src/wrap.ts +++ b/privy/nodejs/src/wrap.ts @@ -2,12 +2,12 @@ import 'dotenv/config'; import {PrivyClient} from '@privy-io/node'; import {createRpc, CTOKEN_PROGRAM_ID} from '@lightprotocol/stateless.js'; import {PublicKey, Transaction, ComputeBudgetProgram} from '@solana/web3.js'; -import {getAssociatedTokenAddressSync, getAccount, TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; +import {getAssociatedTokenAddressSync, getAccount} from '@solana/spl-token'; import { createWrapInstruction, getAssociatedTokenAddressInterface, getSplInterfaceInfos, - createAssociatedTokenAccountInterfaceInstruction, + createAssociatedTokenAccountInterfaceIdempotentInstruction, } from '@lightprotocol/compressed-token'; const wrapTokens = async ( @@ -27,20 +27,21 @@ const wrapTokens = async ( const mintPubkey = new PublicKey(tokenMintAddress); const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); - // Verify SPL ATA balance - const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, TOKEN_2022_PROGRAM_ID); - const ataAccount = await getAccount(connection, splAta, undefined, TOKEN_2022_PROGRAM_ID); - if (ataAccount.amount < BigInt(tokenAmount)) { - throw new Error('Insufficient SPL balance'); - } - - // Get SPL interface info for the mint + // Get SPL interface info — determines whether mint uses SPL or T22 const splInterfaceInfos = await getSplInterfaceInfos(connection, mintPubkey); const splInterfaceInfo = splInterfaceInfos.find( (info) => info.isInitialized, ); if (!splInterfaceInfo) throw new Error('No SPL interface found for this mint'); + // Derive source ATA using the mint's token program (SPL or T22) + const {tokenProgram} = splInterfaceInfo; + const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, tokenProgram); + const ataAccount = await getAccount(connection, splAta, undefined, tokenProgram); + if (ataAccount.amount < BigInt(tokenAmount)) { + throw new Error('Insufficient SPL balance'); + } + // Derive light-token ATA const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); @@ -50,7 +51,7 @@ const wrapTokens = async ( // Ensure light-token ATA exists (idempotent create) transaction.add( - createAssociatedTokenAccountInterfaceInstruction( + createAssociatedTokenAccountInterfaceIdempotentInstruction( fromPubkey, lightTokenAta, fromPubkey, diff --git a/privy/nodejs-privy-light-token/tsconfig.json b/privy/nodejs/tsconfig.json similarity index 100% rename from privy/nodejs-privy-light-token/tsconfig.json rename to privy/nodejs/tsconfig.json From fe31aab87b7e32aea91b68e8758c66588b25c32c Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Thu, 12 Feb 2026 18:47:04 +0000 Subject: [PATCH 03/15] feat: privy wallet integration (nodejs + react) Server-side (nodejs) and client-side (react) Privy wallet examples for light-token transfers, wrap, unwrap, load, and balances. Restructured privy/ layout: - nodejs/: server wallet signing via @privy-io/node - react/: embedded wallet signing via @privy-io/react-auth - scripts/: setup helpers (mint creation, SPL interface registration) Transfer creates destination associated token account idempotently before transferring. --- CLAUDE.md | 62 +++++- package.json | 5 +- privy/CLAUDE.md | 78 ++++--- privy/nodejs/README.md | 42 ++-- privy/nodejs/package.json | 6 +- privy/nodejs/src/balances.ts | 64 +++--- privy/nodejs/src/config.ts | 2 +- privy/nodejs/src/helpers/decompress.ts | 56 ----- privy/nodejs/src/load.ts | 6 +- privy/nodejs/src/transfer.ts | 22 +- privy/nodejs/src/unwrap.ts | 6 +- privy/nodejs/src/wrap.ts | 6 +- .../.env.example | 0 privy/react/README.md | 112 ++++++++++ .../index.html | 0 .../package.json | 10 +- .../src/App.tsx | 3 + .../src/components/reusables/CopyButton.tsx | 0 .../src/components/reusables/Section.tsx | 0 .../sections/TransactionHistory.tsx | 72 ++++++ .../components/sections/TransactionStatus.tsx | 0 .../src/components/sections/TransferForm.tsx | 34 ++- .../src/components/sections/WalletInfo.tsx | 0 .../src/components/ui/Header.tsx | 0 .../integration/hooks.integration.test.ts | 44 ++++ privy/react/src/hooks/__tests__/mock-rpc.ts | 47 ++++ .../__tests__/useLightTokenBalances.test.ts | 207 ++++++++++++++++++ .../__tests__/useTransactionHistory.test.ts | 141 ++++++++++++ .../src/hooks/__tests__/useTransfer.test.ts | 161 ++++++++++++++ .../src/hooks/__tests__/useUnwrap.test.ts | 157 +++++++++++++ .../react/src/hooks/__tests__/useWrap.test.ts | 161 ++++++++++++++ .../src/hooks/index.ts | 0 .../src/hooks/useLightTokenBalances.ts | 46 +++- .../src/hooks/useTransactionHistory.ts | 0 .../src/hooks/useTransfer.ts | 54 +++-- .../src/hooks/useUnwrap.ts | 23 +- .../src/hooks/useWrap.ts | 24 +- .../src/index.css | 0 .../src/main.tsx | 0 privy/react/src/test-setup.ts | 8 + .../src/vite-env.d.ts | 0 .../tsconfig.json | 0 .../tsconfig.node.json | 0 .../vite.config.ts | 9 +- privy/react/vitest.integration.config.ts | 15 ++ privy/scripts/.env.example | 3 + privy/scripts/README.md | 34 +++ privy/scripts/package.json | 22 ++ .../src}/mint-light-token.ts | 25 ++- .../src/helpers => scripts/src}/mint-spl.ts | 20 +- .../src}/register-spl-interface.ts | 16 +- privy/scripts/tsconfig.json | 12 + 52 files changed, 1577 insertions(+), 238 deletions(-) delete mode 100644 privy/nodejs/src/helpers/decompress.ts rename privy/{react-privy-light-token => react}/.env.example (100%) create mode 100644 privy/react/README.md rename privy/{react-privy-light-token => react}/index.html (100%) rename privy/{react-privy-light-token => react}/package.json (72%) rename privy/{react-privy-light-token => react}/src/App.tsx (96%) rename privy/{react-privy-light-token => react}/src/components/reusables/CopyButton.tsx (100%) rename privy/{react-privy-light-token => react}/src/components/reusables/Section.tsx (100%) create mode 100644 privy/react/src/components/sections/TransactionHistory.tsx rename privy/{react-privy-light-token => react}/src/components/sections/TransactionStatus.tsx (100%) rename privy/{react-privy-light-token => react}/src/components/sections/TransferForm.tsx (86%) rename privy/{react-privy-light-token => react}/src/components/sections/WalletInfo.tsx (100%) rename privy/{react-privy-light-token => react}/src/components/ui/Header.tsx (100%) create mode 100644 privy/react/src/hooks/__tests__/integration/hooks.integration.test.ts create mode 100644 privy/react/src/hooks/__tests__/mock-rpc.ts create mode 100644 privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts create mode 100644 privy/react/src/hooks/__tests__/useTransactionHistory.test.ts create mode 100644 privy/react/src/hooks/__tests__/useTransfer.test.ts create mode 100644 privy/react/src/hooks/__tests__/useUnwrap.test.ts create mode 100644 privy/react/src/hooks/__tests__/useWrap.test.ts rename privy/{react-privy-light-token => react}/src/hooks/index.ts (100%) rename privy/{react-privy-light-token => react}/src/hooks/useLightTokenBalances.ts (73%) rename privy/{react-privy-light-token => react}/src/hooks/useTransactionHistory.ts (100%) rename privy/{react-privy-light-token => react}/src/hooks/useTransfer.ts (62%) rename privy/{react-privy-light-token => react}/src/hooks/useUnwrap.ts (87%) rename privy/{react-privy-light-token => react}/src/hooks/useWrap.ts (86%) rename privy/{react-privy-light-token => react}/src/index.css (100%) rename privy/{react-privy-light-token => react}/src/main.tsx (100%) create mode 100644 privy/react/src/test-setup.ts rename privy/{react-privy-light-token => react}/src/vite-env.d.ts (100%) rename privy/{react-privy-light-token => react}/tsconfig.json (100%) rename privy/{react-privy-light-token => react}/tsconfig.node.json (100%) rename privy/{react-privy-light-token => react}/vite.config.ts (51%) create mode 100644 privy/react/vitest.integration.config.ts create mode 100644 privy/scripts/.env.example create mode 100644 privy/scripts/README.md create mode 100644 privy/scripts/package.json rename privy/{nodejs/src/helpers => scripts/src}/mint-light-token.ts (76%) rename privy/{nodejs/src/helpers => scripts/src}/mint-spl.ts (74%) rename privy/{nodejs/src/helpers => scripts/src}/register-spl-interface.ts (76%) create mode 100644 privy/scripts/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index 0d73154..acbd2be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,39 @@ cargo test-sbf -p -- --test-threads=1 # 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 (use local filesystem keypair, not Privy) +npm run mint:light-token # 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 +npm run decompress # Decompress light-token ATA to T22 ATA +``` + +### 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 @@ -69,7 +102,7 @@ curl http://127.0.0.1:8784/health ### Workspace layout -Root `package.json` defines npm workspaces: `typescript-client` and `toolkits/payments-and-wallets`. Dependencies are hoisted to root. +Root `package.json` defines npm workspaces: `typescript-client`, `toolkits/payments-and-wallets`, `privy/nodejs`, and `privy/react`. Dependencies are hoisted to root. - `typescript-client/` — Core examples: `actions/` (high-level) and `instructions/` (low-level) - `rust-client/` — Rust client examples as cargo examples @@ -80,6 +113,9 @@ Root `package.json` defines npm workspaces: `typescript-client` and `toolkits/pa - `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 @@ -126,12 +162,34 @@ const payer = Keypair.fromSecretKey( **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`. + +**Signing pattern**: Build unsigned `Transaction` → serialize with `requireAllSignatures: false` → sign via `privy.wallets().solana().signTransaction(walletId, { transaction, authorization_context })` → deserialize → `sendRawTransaction`. + +**Two categories of scripts**: + +- **App operations** (`src/*.ts`): Use Privy server wallet signing. These are what an app calls at runtime: `transfer`, `wrap`, `unwrap`, `load`, `balances`, `get-transaction-history`. +- **Setup helpers** (`src/helpers/*.ts`): Use local filesystem keypair (`~/.config/solana/id.json`), not Privy. These run once to create test mints and fund balances. + +**Config**: All env vars are centralized in `src/config.ts` with validation. Scripts import from `./config.js`. + +**Instruction-level patterns**: + +- Wrap and unwrap require `ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 })`. Transfer does not. +- Wrap calls `getSplInterfaceInfos` to find the initialized SPL interface PDA and its `tokenProgram`. +- Unwrap imports `createUnwrapInstruction` from `@lightprotocol/compressed-token/unified` (not the main export). +- Transfer auto-loads cold balance before sending via `createLoadAtaInstructionsFromInterface`. +- Balances queries three sources: hot (light-token ATA via `getAtaInterface`), cold (compressed via `getCompressedTokenBalancesByOwnerV2`), SPL T22 (raw account data parsing from `getTokenAccountsByOwner`). + ### Key dependencies -- `@lightprotocol/compressed-token` beta — TypeScript token client (also exports `/unified` subpath for payments toolkit) +- `@lightprotocol/compressed-token` beta — TypeScript token client (also exports `/unified` subpath for unwrap, load, balances) - `@lightprotocol/stateless.js` beta — TypeScript RPC client (`createRpc`, `buildAndSignTx`, `sendAndConfirmTx`) - `@solana/web3.js` 1.98.x — Solana web3 (v1, not v2) - `@solana/spl-token` 0.4.x — SPL token operations (used for T22 interop) +- `@privy-io/node` ^0.1.0-alpha.2 — Server-side Privy SDK (Node.js scripts) - `light-sdk` 0.19.x — Rust core SDK - `light-token` 0.4.x — Rust token operations - Anchor 0.31.1, Solana SDK 2.2, Rust 1.90.0 diff --git a/package.json b/package.json index 57dc1db..1307709 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "workspaces": [ "typescript-client", "toolkits/payments-and-wallets", - "privy/react-privy-light-token", - "privy/nodejs" + "privy/react", + "privy/nodejs", + "privy/scripts" ], "scripts": { "toolkit:payments": "npm run -w toolkits/payments-and-wallets" diff --git a/privy/CLAUDE.md b/privy/CLAUDE.md index 0758894..c73b14c 100644 --- a/privy/CLAUDE.md +++ b/privy/CLAUDE.md @@ -4,15 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -Two example apps demonstrating Privy wallet integration with Light Token on Solana devnet. Both apps show transfer, wrap (SPL to light-token), and unwrap (light-token to SPL T22) flows using Privy-managed wallets for transaction signing. +Two example apps demonstrating Privy wallet integration with Light Token on Solana devnet. Both apps show transfer, wrap, unwrap, load, and balance query flows using Privy-managed wallets for transaction signing. ## Sub-projects -### `nodejs-privy-light-token/` — Server-side (Node.js) +### `nodejs/` — Server-side (Node.js) Backend scripts using `@privy-io/node` with server-side wallet signing via `TREASURY_AUTHORIZATION_KEY`. Each script is a standalone operation run with `tsx`. -### `react-privy-light-token/` — Client-side (React + Vite) +### `react/` — Client-side (React + Vite) — WIP Browser app using `@privy-io/react-auth` with client-side wallet signing via `useSignTransaction`. Privy creates embedded Solana wallets on login. Vite dev server with Tailwind CSS v4 and `vite-plugin-node-polyfills` for Buffer. @@ -21,27 +21,34 @@ Browser app using `@privy-io/react-auth` with client-side wallet signing via `us ### Node.js scripts ```bash -cd nodejs-privy-light-token +cd nodejs npm install cp .env.example .env # fill in all values -npm run transfer # light-token ATA → ATA transfer -npm run wrap # SPL → light-token ATA -npm run unwrap # light-token ATA → SPL T22 -npm run mint:light-token # create light-token mint + mint tokens (uses filesystem wallet) -npm run mint:spl # create SPL mint + mint tokens (uses filesystem wallet) +# 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 (use local filesystem keypair, not Privy) +npm run mint:light-token # 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 +npm run decompress # Decompress light-token ATA to T22 ATA ``` ### React app ```bash -cd react-privy-light-token +cd react npm install cp .env.example .env # fill in VITE_PRIVY_APP_ID and VITE_HELIUS_RPC_URL npm run dev # start Vite dev server npm run build # production build -npm run preview # preview production build ``` ## Architecture @@ -54,36 +61,46 @@ Both apps follow the same flow: build an unsigned `Transaction`, serialize with **React** — signs via `useSignTransaction` hook: `signTransaction({ transaction, wallet, chain: 'solana:devnet' })`. Privy handles embedded wallet key management client-side. +### Node.js modules (`nodejs/src/`) + +Standalone async functions, each creating their own `PrivyClient` and `createRpc`: + +- `transfer.ts` — `createTransferInterfaceInstruction`, auto-loads cold balance via `createLoadAtaInstructionsFromInterface` +- `wrap.ts` — `createWrapInstruction` with SPL interface lookup via `getSplInterfaceInfos` +- `unwrap.ts` — `createUnwrapInstruction` from `@lightprotocol/compressed-token/unified` +- `load.ts` — `createLoadAtaInstructionsFromInterface` to consolidate cold + SPL + T22 into light-token ATA +- `balances.ts` — queries hot (`getAtaInterface`), cold (`getCompressedTokenBalancesByOwnerV2`), SPL T22 (`getTokenAccountsByOwner` + raw data parsing) +- `get-transaction-history.ts` — `getSignaturesForOwnerInterface` +- `config.ts` — centralized env var exports with validation + +**Setup helpers** (`nodejs/src/helpers/`): + +- `mint-light-token.ts` — `createMintInterface` + mint + wrap + transfer to treasury (filesystem wallet) +- `mint-spl.ts` — `createMintToInstruction` to existing mint (filesystem wallet) +- `register-spl-interface.ts` — `createSplInterface` on existing mint (filesystem wallet) +- `decompress.ts` — `decompressInterface` from light-token ATA to T22 ATA (filesystem wallet) + ### Transaction routing (React `TransferForm`) The `TransferForm` component routes actions based on `TokenBalance.isLightToken`: + - Light-token balance → `useTransfer` → `createTransferInterfaceInstruction` - SPL balance → `useWrap` → `createWrapInstruction` (wraps to own light-token ATA) - SOL → display only, no transfer action -### React hooks (`src/hooks/`) +### React hooks (`react/src/hooks/`) Each hook returns `{ actionFn, isLoading }` and accepts `{ params, wallet, signTransaction }`: + - `useTransfer` — light-token ATA to ATA transfer - `useWrap` — SPL to light-token (creates light-token ATA idempotently, verifies SPL balance) - `useUnwrap` — light-token to SPL T22 (creates T22 ATA if missing) - `useLightTokenBalances` — fetches SOL, SPL (Token Program), and light-token (T22) balances by parsing raw account data - `useTransactionHistory` — queries `getSignaturesForOwnerInterface` -### Node.js modules (`src/`) - -Standalone async functions, each creating their own `PrivyClient` and `createRpc`: -- `transfer.ts` — `createTransferInterfaceInstruction` -- `wrap.ts` — `createWrapInstruction` with SPL interface lookup -- `unwrap.ts` — `createUnwrapInstruction` from `@lightprotocol/compressed-token/unified` -- `balances.ts` — parses T22 and SPL account data for balances -- `get-transaction-history.ts` — `getSignaturesForOwnerInterface` -- `helpers/mint-light-token.ts` — creates light-token mint with validity proof (uses filesystem wallet, not Privy) -- `helpers/mint-spl.ts` — creates standard SPL mint (uses filesystem wallet, not Privy) - ## Environment variables -### Node.js (`.env`) +### Node.js (`nodejs/.env`) | Variable | Required | Purpose | |---|---|---| @@ -91,11 +108,14 @@ Standalone async functions, each creating their own `PrivyClient` and `createRpc | `PRIVY_APP_SECRET` | Yes | Privy server-side secret | | `TREASURY_WALLET_ID` | Yes | Privy wallet ID for signing | | `TREASURY_WALLET_ADDRESS` | Yes | Public key of treasury wallet | -| `TREASURY_AUTHORIZATION_KEY` | Yes | Private key authorizing wallet operations | +| `TREASURY_AUTHORIZATION_KEY` | Yes | EC private key for ECDSA transaction authorization | | `HELIUS_RPC_URL` | Yes | Helius RPC endpoint (devnet) | | `TEST_MINT` | No | Token mint address for scripts | +| `DEFAULT_TEST_RECIPIENT` | No | Defaults to `TREASURY_WALLET_ADDRESS` | +| `DEFAULT_AMOUNT` | No | Defaults to `0.001` | +| `DEFAULT_DECIMALS` | No | Defaults to `9` | -### React (`.env`) +### React (`react/.env`) | Variable | Required | Purpose | |---|---|---| @@ -106,7 +126,7 @@ Standalone async functions, each creating their own `PrivyClient` and `createRpc - `@privy-io/node` ^0.1.0-alpha.2 — server-side Privy SDK - `@privy-io/react-auth` ^3.9.1 — client-side Privy SDK -- `@lightprotocol/compressed-token` beta — light-token instructions (also exports `/unified` subpath for unwrap) +- `@lightprotocol/compressed-token` beta — light-token instructions (also exports `/unified` subpath for unwrap, load, balances) - `@lightprotocol/stateless.js` beta — RPC client (`createRpc`) - `@solana/web3.js` 1.98.4 — Solana web3 v1 - `@solana/spl-token` ^0.4.13 — SPL token operations (T22 ATA creation, balance checks) @@ -115,8 +135,8 @@ Standalone async functions, each creating their own `PrivyClient` and `createRpc ## Important patterns - Wrap and unwrap require `ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 })`. Transfer does not. -- Wrap requires `getSplInterfaceInfos` to find the initialized SPL interface for the mint. +- Wrap calls `getSplInterfaceInfos` to find the initialized SPL interface and its `tokenProgram`. - Unwrap imports from `@lightprotocol/compressed-token/unified` (not the main export). -- Helper mint scripts use filesystem wallet (`~/.config/solana/id.json`), not Privy, because they need a keypair signer for mint authority. +- Helper scripts use filesystem wallet (`~/.config/solana/id.json`), not Privy, because they need a keypair signer for mint authority. - The React app derives WebSocket URL from RPC URL by replacing `https://` with `wss://`. - Both apps target `solana:devnet` (CAIP-2 chain ID `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`). \ No newline at end of file diff --git a/privy/nodejs/README.md b/privy/nodejs/README.md index 2f1df7b..2d08ee6 100644 --- a/privy/nodejs/README.md +++ b/privy/nodejs/README.md @@ -24,9 +24,9 @@ Light Token gives you rent-free token accounts on Solana. Light-token accounts h ### Source files - **[transfer.ts](src/transfer.ts)** — Transfer light-tokens between wallets. Auto-loads cold balance before sending. -- **[wrap.ts](src/wrap.ts)** — Wrap SPL or T22 tokens into light-token ATA. -- **[unwrap.ts](src/unwrap.ts)** — Unwrap light-token ATA back to SPL or T22. -- **[load.ts](src/load.ts)** — Consolidate cold (compressed) and SPL/T22 balances into the light-token ATA. +- **[wrap.ts](src/wrap.ts)** — Wrap SPL or T22 tokens into light-token associated token account. +- **[unwrap.ts](src/unwrap.ts)** — Unwrap light-token associated token account back to SPL or T22. +- **[load.ts](src/load.ts)** — Consolidate cold (compressed) and SPL/T22 balances into the light-token associated token account. - **[balances.ts](src/balances.ts)** — Query balance breakdown: hot, cold, SPL/T22, and SOL. - **[get-transaction-history.ts](src/get-transaction-history.ts)** — Fetch transaction history for light-token operations. @@ -50,13 +50,13 @@ const hasInterface = infos.some((info) => info.isInitialized); **Register one if it doesn't:** ```bash -# For an existing SPL or T22 mint -npm run register:spl-interface +# For an existing SPL or T22 mint (from privy/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 ATA. Set `TEST_MINT` in `.env` to the USDC mint address. +**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. ### 2. Recipients need balance visibility @@ -68,10 +68,10 @@ import { getAtaInterface } from "@lightprotocol/compressed-token/unified"; const rpc = createRpc(process.env.HELIUS_RPC_URL); -// Hot balance (light-token ATA) +// Hot balance (light-token associated token account) const { parsed, isCold } = await getAtaInterface(rpc, ata, owner, mint); -// Cold balance (compressed tokens not yet loaded into ATA) +// Cold balance (compressed tokens not yet loaded into associated token account) const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner, { mint }); ``` @@ -106,22 +106,26 @@ These are the operations your app calls at runtime. Transactions are signed serv | Command | What it does | | ------- | ----------- | | `npm run transfer` | Transfer light-tokens between wallets. Auto-loads cold balance before sending. | -| `npm run wrap` | Wrap SPL or T22 tokens into light-token ATA. Auto-detects token program from the mint's interface PDA. | -| `npm run unwrap` | Unwrap light-token ATA back to SPL or T22. Auto-detects token program. | -| `npm run load` | Consolidate cold (compressed) and SPL/T22 balances into the light-token ATA. | +| `npm run wrap` | Wrap SPL or T22 tokens into light-token associated token account. Auto-detects token program from the mint's interface PDA. | +| `npm run unwrap` | Unwrap light-token associated token account back to SPL or T22. Auto-detects token program. | +| `npm run load` | Consolidate cold (compressed) and SPL/T22 balances into the light-token associated token account. | | `npm run balances` | Query balance breakdown: hot, cold, SPL/T22, and SOL. | | `npm run history` | Fetch transaction history for light-token interface operations. | ### Setup helpers (local keypair) -Run once to create test mints and fund balances. These use the Solana CLI keypair at `~/.config/solana/id.json`, not Privy. +Setup scripts live in [`privy/scripts/`](../scripts/). They use the Solana CLI keypair at `~/.config/solana/id.json` and don't require Privy credentials. + +```bash +cd ../scripts +cp .env.example .env # set HELIUS_RPC_URL +``` | Command | What it does | | ------- | ----------- | -| `npm run mint:light-token` | Create an SPL or T22 mint with interface PDA, mint tokens, wrap, and transfer to treasury. | -| `npm run mint:spl` | 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. | -| `npm run decompress` | Decompress light-token ATA to a T22 ATA. | +| `npm run mint:light-token [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 @@ -129,10 +133,10 @@ Run these in order to see the full flow on devnet: ```bash # 1. Create a test mint with interface PDA + fund the treasury wallet -npm run mint:light-token +cd ../scripts && npm run mint:light-token # 2. Check balances — should show 100 tokens in hot balance -npm run balances +cd ../nodejs && npm run balances # 3. Transfer light-tokens to the default recipient npm run transfer @@ -140,6 +144,6 @@ npm run transfer # 4. Unwrap light-tokens back to SPL/T22 npm run unwrap -# 5. Wrap SPL/T22 tokens back into light-token ATA +# 5. Wrap SPL/T22 tokens back into light-token associated token account npm run wrap ``` diff --git a/privy/nodejs/package.json b/privy/nodejs/package.json index 306bb78..6f3ce6c 100644 --- a/privy/nodejs/package.json +++ b/privy/nodejs/package.json @@ -8,11 +8,7 @@ "wrap": "tsx src/wrap.ts", "unwrap": "tsx src/unwrap.ts", "balances": "tsx src/balances.ts", - "history": "tsx src/get-transaction-history.ts", - "mint:light-token": "tsx src/helpers/mint-light-token.ts", - "mint:spl": "tsx src/helpers/mint-spl.ts", - "register:spl-interface": "tsx src/helpers/register-spl-interface.ts", - "decompress": "tsx src/helpers/decompress.ts" + "history": "tsx src/get-transaction-history.ts" }, "dependencies": { "@privy-io/node": "^0.1.0-alpha.2", diff --git a/privy/nodejs/src/balances.ts b/privy/nodejs/src/balances.ts index fd6d1c6..2bfe8e7 100644 --- a/privy/nodejs/src/balances.ts +++ b/privy/nodejs/src/balances.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; import {PublicKey, LAMPORTS_PER_SOL} from '@solana/web3.js'; -import {TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; +import {TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; import {createRpc} from '@lightprotocol/stateless.js'; import { getAtaInterface, @@ -11,11 +11,11 @@ interface BalanceBreakdown { token?: { mint: string; unified: number; - hasColdBalance: boolean; decimals: number; hot: number; cold: number; - splT22: number; + spl: number; + t22: number; }; } @@ -46,18 +46,17 @@ export async function getBalances( const ata = getAssociatedTokenAddressInterface(mint, owner); const decimals = 9; - let hasColdBalance = false; let hot = 0; let cold = 0; - let splT22 = 0; + let spl = 0; + let t22 = 0; - // 1. Hot balance (light-token ATA on-chain) + // 1. Hot balance (light-token associated token account on-chain) try { - const {parsed, isCold} = await getAtaInterface(rpc, ata, owner, mint); + const {parsed} = await getAtaInterface(rpc, ata, owner, mint); hot = toUiAmount(parsed.amount, decimals); - hasColdBalance = isCold; } catch { - // ATA may not exist yet + // Associated token account may not exist yet } // 2. Cold balance (compressed token accounts) @@ -72,26 +71,33 @@ export async function getBalances( // No compressed accounts } - // 3. SPL T22 balance (standard Token-2022 accounts) + // 3. SPL balance (standard token program 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 accountMint = new PublicKey(buf.subarray(0, 32)); + if (!accountMint.equals(mint)) continue; + spl += toUiAmount(buf.readBigUInt64LE(64), decimals); + } + } catch { + // No SPL accounts + } + + // 4. T22 balance (Token-2022 accounts) try { const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { programId: TOKEN_2022_PROGRAM_ID, }); for (const {account} of t22Accounts.value) { - const data = account.data; - const buf = - data instanceof Buffer - ? data - : typeof data === 'object' && 'length' in data - ? Buffer.from(data as Uint8Array) - : null; + const buf = toBuffer(account.data); if (!buf || buf.length < 72) continue; - const accountMint = new PublicKey(buf.subarray(0, 32)); if (!accountMint.equals(mint)) continue; - - const amount = buf.readBigUInt64LE(64); - splT22 += toUiAmount(amount, decimals); + t22 += toUiAmount(buf.readBigUInt64LE(64), decimals); } } catch { // No T22 accounts @@ -102,16 +108,22 @@ export async function getBalances( result.token = { mint: mintAddress, unified, - hasColdBalance, decimals, hot, cold, - splT22, + spl, + t22, }; return result; } +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; +} + function toUiAmount(raw: bigint | {toNumber: () => number}, decimals: number): number { const value = typeof raw === 'bigint' ? Number(raw) : raw.toNumber(); return value / 10 ** decimals; @@ -128,10 +140,10 @@ getBalances(TREASURY_WALLET_ADDRESS, TEST_MINT) const t = b.token; const short = t.mint.slice(0, 6) + '...'; console.log(`Token ${short} (unified): ${t.unified}`); - console.log(` Hot (light-token ATA): ${t.hot}`); + console.log(` Hot (light-token associated token account): ${t.hot}`); console.log(` Cold (compressed): ${t.cold}`); - console.log(` SPL T22: ${t.splT22}`); - console.log(` Has cold balance: ${t.hasColdBalance}`); + console.log(` SPL: ${t.spl}`); + console.log(` T22: ${t.t22}`); } }) .catch(console.error); diff --git a/privy/nodejs/src/config.ts b/privy/nodejs/src/config.ts index 8c4cdc0..4750db0 100644 --- a/privy/nodejs/src/config.ts +++ b/privy/nodejs/src/config.ts @@ -10,7 +10,7 @@ export const TEST_MINT = process.env.TEST_MINT || ''; export const SOLANA_CAIP2_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; -// Default values for standalone scripts +// Default values for app scripts export const DEFAULT_TEST_RECIPIENT = process.env.DEFAULT_TEST_RECIPIENT || TREASURY_WALLET_ADDRESS; export const DEFAULT_AMOUNT = process.env.DEFAULT_AMOUNT || '0.001'; export const DEFAULT_DECIMALS = process.env.DEFAULT_DECIMALS || '9'; diff --git a/privy/nodejs/src/helpers/decompress.ts b/privy/nodejs/src/helpers/decompress.ts deleted file mode 100644 index a1ba904..0000000 --- a/privy/nodejs/src/helpers/decompress.ts +++ /dev/null @@ -1,56 +0,0 @@ -import 'dotenv/config'; -import {Keypair, PublicKey} from '@solana/web3.js'; -import {createRpc} from '@lightprotocol/stateless.js'; -import {decompressInterface} from '@lightprotocol/compressed-token'; -import {createAssociatedTokenAccount, TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; -import {homedir} from 'os'; -import {readFileSync} from 'fs'; - -const decompress = async (mintAddress: string, amount: bigint) => { - const connection = createRpc(process.env.HELIUS_RPC_URL!); - - // Load filesystem wallet (not Privy — helper script) - const payer = Keypair.fromSecretKey( - new Uint8Array( - JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) - ) - ); - - const mint = new PublicKey(mintAddress); - - // Create T22 ATA if needed - try { - await createAssociatedTokenAccount( - connection, - payer, - mint, - payer.publicKey, - undefined, - TOKEN_2022_PROGRAM_ID, - ); - console.log('Created T22 ATA'); - } catch (e: any) { - // ATA already exists - if (!e.message?.includes('already in use')) { - throw e; - } - console.log('T22 ATA already exists'); - } - - const tx = await decompressInterface(connection, payer, payer, mint, amount); - - console.log('Mint:', mint.toBase58()); - console.log('Decompress signature:', tx); - - return tx; -}; - -export {decompress}; -export default decompress; - -// --- main --- -import {TEST_MINT} from '../config.js'; -const AMOUNT = BigInt(10 * 10 ** 9); // 10 tokens with 9 decimals -decompress(TEST_MINT, AMOUNT) - .then((result) => console.log('Result:', result)) - .catch(console.error); diff --git a/privy/nodejs/src/load.ts b/privy/nodejs/src/load.ts index 0dc24dd..bddf52c 100644 --- a/privy/nodejs/src/load.ts +++ b/privy/nodejs/src/load.ts @@ -25,16 +25,16 @@ const loadLightTokens = async ( const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, ownerPubkey); - // Query all token sources for the ATA + // Query all token sources for the associated token account const ataInfo = await getAtaInterface(connection, lightTokenAta, ownerPubkey, mintPubkey); - // Consolidate all sources (cold compressed + SPL + T22) into light-token ATA + // Consolidate all sources (cold compressed + SPL + T22) into light-token associated token account const ixs = await createLoadAtaInstructionsFromInterface( connection, ownerPubkey, ataInfo, undefined, - true, // wrap=true: consolidate SPL + T22 + cold into light ATA + true, // wrap=true: consolidate SPL + T22 + cold into light associated token account ); if (ixs.length === 0) { diff --git a/privy/nodejs/src/transfer.ts b/privy/nodejs/src/transfer.ts index c704412..8405eaf 100644 --- a/privy/nodejs/src/transfer.ts +++ b/privy/nodejs/src/transfer.ts @@ -1,10 +1,11 @@ import 'dotenv/config'; import {PrivyClient} from '@privy-io/node'; -import {createRpc} from '@lightprotocol/stateless.js'; +import {createRpc, CTOKEN_PROGRAM_ID} from '@lightprotocol/stateless.js'; import {PublicKey, Transaction} from '@solana/web3.js'; import { createTransferInterfaceInstruction, getAssociatedTokenAddressInterface, + createAssociatedTokenAccountInterfaceIdempotentInstruction, } from '@lightprotocol/compressed-token'; import { getAtaInterface, @@ -31,14 +32,25 @@ const transferLightTokens = async ( const mintPubkey = new PublicKey(tokenMintAddress); const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); - // Derive light-token ATAs + // Derive light-token associated token accounts const sourceAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); const destAta = getAssociatedTokenAddressInterface(mintPubkey, toPubkey); // Build transaction const transaction = new Transaction(); - // Consolidate cold + SPL + T22 into light ATA if needed + // Ensure destination associated token account exists (idempotent) + transaction.add( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + fromPubkey, + destAta, + toPubkey, + mintPubkey, + CTOKEN_PROGRAM_ID, + ), + ); + + // Consolidate cold + SPL + T22 into light associated token account if needed try { const ataInfo = await getAtaInterface(connection, sourceAta, fromPubkey, mintPubkey); const loadIxs = await createLoadAtaInstructionsFromInterface( @@ -46,10 +58,10 @@ const transferLightTokens = async ( ); if (loadIxs.length > 0) transaction.add(...loadIxs); } catch { - // ATA doesn't exist yet + // Associated token account doesn't exist yet } - // Transfer light-token ATA → light-token ATA + // Transfer light-token associated token account → light-token associated token account transaction.add(createTransferInterfaceInstruction(sourceAta, destAta, fromPubkey, tokenAmount)); const {blockhash} = await connection.getLatestBlockhash(); diff --git a/privy/nodejs/src/unwrap.ts b/privy/nodejs/src/unwrap.ts index 38471cc..e0171da 100644 --- a/privy/nodejs/src/unwrap.ts +++ b/privy/nodejs/src/unwrap.ts @@ -40,17 +40,17 @@ const unwrapTokens = async ( const {tokenProgram} = splInterfaceInfo; - // Source: light-token ATA + // Source: light-token associated token account const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); - // Destination: SPL/T22 ATA (derived using the mint's token program) + // Destination: SPL/T22 associated token account (derived using the mint's token program) const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, tokenProgram); // Build transaction const transaction = new Transaction(); transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 200_000})); - // Create SPL/T22 ATA if needed + // Create SPL/T22 associated token account if needed try { await getAccount(connection, splAta, undefined, tokenProgram); } catch (e) { diff --git a/privy/nodejs/src/wrap.ts b/privy/nodejs/src/wrap.ts index ee444ca..59f8db6 100644 --- a/privy/nodejs/src/wrap.ts +++ b/privy/nodejs/src/wrap.ts @@ -34,7 +34,7 @@ const wrapTokens = async ( ); if (!splInterfaceInfo) throw new Error('No SPL interface found for this mint'); - // Derive source ATA using the mint's token program (SPL or T22) + // Derive source associated token account using the mint's token program (SPL or T22) const {tokenProgram} = splInterfaceInfo; const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, tokenProgram); const ataAccount = await getAccount(connection, splAta, undefined, tokenProgram); @@ -42,14 +42,14 @@ const wrapTokens = async ( throw new Error('Insufficient SPL balance'); } - // Derive light-token ATA + // Derive light-token associated token account const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); // Build transaction const transaction = new Transaction(); transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 200_000})); - // Ensure light-token ATA exists (idempotent create) + // Ensure light-token associated token account exists (idempotent create) transaction.add( createAssociatedTokenAccountInterfaceIdempotentInstruction( fromPubkey, diff --git a/privy/react-privy-light-token/.env.example b/privy/react/.env.example similarity index 100% rename from privy/react-privy-light-token/.env.example rename to privy/react/.env.example diff --git a/privy/react/README.md b/privy/react/README.md new file mode 100644 index 0000000..3c00e00 --- /dev/null +++ b/privy/react/README.md @@ -0,0 +1,112 @@ +# Privy + Light Token (React) + +Privy handles user authentication and embedded wallet management. You build transactions with light-token instructions and Privy signs them client-side: + +1. Authenticate with Privy +2. Build unsigned transaction +3. Sign transaction using Privy's embedded wallet +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. +- **[useLightTokenBalances.ts](src/hooks/useLightTokenBalances.ts)** — Query balance breakdown: SOL, SPL, T22, light-token, and compressed. +- **[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)** — Privy login and 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 privy/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 your credentials +``` + +### Environment variables + +| Variable | Description | +| -------- | ----------- | +| `VITE_PRIVY_APP_ID` | From the [Privy console](https://console.privy.io). | +| `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 [`privy/scripts/`](../scripts/). They use the Solana CLI keypair at `~/.config/solana/id.json` and don't require Privy credentials. + +```bash +cd ../scripts +cp .env.example .env # set HELIUS_RPC_URL +``` + +| Command | What it does | +| ------- | ----------- | +| `npm run mint:light-token [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 Privy wallet +cd ../scripts && npm run mint:light-token + +# 2. Start the dev server +cd ../react && npm run dev +``` + +Then in the browser: +1. Connect your wallet via Privy +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) \ No newline at end of file diff --git a/privy/react-privy-light-token/index.html b/privy/react/index.html similarity index 100% rename from privy/react-privy-light-token/index.html rename to privy/react/index.html diff --git a/privy/react-privy-light-token/package.json b/privy/react/package.json similarity index 72% rename from privy/react-privy-light-token/package.json rename to privy/react/package.json index 8a8a6a5..52dc965 100644 --- a/privy/react-privy-light-token/package.json +++ b/privy/react/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { "@heroicons/react": "^2.2.0", @@ -22,12 +25,15 @@ }, "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" + "vite-plugin-node-polyfills": "^0.25.0", + "vitest": "^4.0.18" } } diff --git a/privy/react-privy-light-token/src/App.tsx b/privy/react/src/App.tsx similarity index 96% rename from privy/react-privy-light-token/src/App.tsx rename to privy/react/src/App.tsx index 2fef25a..ab9dd47 100644 --- a/privy/react-privy-light-token/src/App.tsx +++ b/privy/react/src/App.tsx @@ -6,6 +6,7 @@ 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() { @@ -103,6 +104,8 @@ export default function App() { /> + + diff --git a/privy/react-privy-light-token/src/components/reusables/CopyButton.tsx b/privy/react/src/components/reusables/CopyButton.tsx similarity index 100% rename from privy/react-privy-light-token/src/components/reusables/CopyButton.tsx rename to privy/react/src/components/reusables/CopyButton.tsx diff --git a/privy/react-privy-light-token/src/components/reusables/Section.tsx b/privy/react/src/components/reusables/Section.tsx similarity index 100% rename from privy/react-privy-light-token/src/components/reusables/Section.tsx rename to privy/react/src/components/reusables/Section.tsx 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-privy-light-token/src/components/sections/TransactionStatus.tsx b/privy/react/src/components/sections/TransactionStatus.tsx similarity index 100% rename from privy/react-privy-light-token/src/components/sections/TransactionStatus.tsx rename to privy/react/src/components/sections/TransactionStatus.tsx diff --git a/privy/react-privy-light-token/src/components/sections/TransferForm.tsx b/privy/react/src/components/sections/TransferForm.tsx similarity index 86% rename from privy/react-privy-light-token/src/components/sections/TransferForm.tsx rename to privy/react/src/components/sections/TransferForm.tsx index d5d332d..7d750a5 100644 --- a/privy/react-privy-light-token/src/components/sections/TransferForm.tsx +++ b/privy/react/src/components/sections/TransferForm.tsx @@ -80,23 +80,27 @@ export default function TransferForm({ // Transaction routing based on token state if (isNative) { - // SOL: display only, no transfer action for light-token - alert('SOL transfers are not supported in light-token mode'); + alert('SOL transfers are not supported'); return; } else if (isLightToken) { - // Light-token ATA: transfer via createTransferInterfaceInstruction + // Light-token: direct transfer (auto-loads cold balance) signature = await transfer({ params: { ownerPublicKey: selectedWallet, mint: selectedMint, toAddress: recipientAddress, amount: amountNum, decimals }, wallet, signTransaction, }); } else { - // Regular SPL: wrap to own light-token ATA - signature = await wrap({ + // SPL: wrap to own light-token ATA, then transfer to recipient + await wrap({ params: { ownerPublicKey: selectedWallet, mint: selectedMint, amount: amountNum, decimals }, wallet, signTransaction, }); + signature = await transfer({ + params: { ownerPublicKey: selectedWallet, mint: selectedMint, toAddress: recipientAddress, amount: amountNum, decimals }, + wallet, + signTransaction, + }); } setRecipientAddress(''); @@ -161,29 +165,23 @@ export default function TransferForm({ ) : ( balances.map((balance) => { - const formatBalance = (amount: string, decimals: number): string => { - const num = BigInt(amount); - const divisor = BigInt(10 ** decimals); + const formatBalance = (amt: string, dec: number): string => { + const num = BigInt(amt); + const divisor = BigInt(10 ** dec); const whole = num / divisor; const fraction = num % divisor; - const fractionStr = fraction.toString().padStart(decimals, '0').replace(/0+$/, ''); + const fractionStr = fraction.toString().padStart(dec, '0').replace(/0+$/, ''); return fractionStr ? `${whole}.${fractionStr}` : whole.toString(); }; const formattedAmount = formatBalance(balance.amount, balance.decimals); - const typeLabel = balance.isNative - ? 'SOL' - : balance.isLightToken - ? 'Light' - : 'SPL'; - const label = balance.isNative ? `SOL - ${formattedAmount}` - : `[${typeLabel}] ${balance.mint.slice(0, 8)}...${balance.mint.slice(-4)} - ${formattedAmount}`; + : `${balance.mint.slice(0, 8)}...${balance.mint.slice(-4)} - ${formattedAmount}`; return ( - ); @@ -236,7 +234,7 @@ export default function TransferForm({ disabled={isLoading || !selectedWallet || !selectedMint || !recipientAddress} className="button-primary w-full" > - {isLoading ? 'Sending...' : selectedBalance?.isNative ? 'SOL (view only)' : selectedBalance?.isLightToken ? 'Transfer' : 'Wrap to Light Token'} + {isLoading ? 'Sending...' : 'Send'} )} diff --git a/privy/react-privy-light-token/src/components/sections/WalletInfo.tsx b/privy/react/src/components/sections/WalletInfo.tsx similarity index 100% rename from privy/react-privy-light-token/src/components/sections/WalletInfo.tsx rename to privy/react/src/components/sections/WalletInfo.tsx diff --git a/privy/react-privy-light-token/src/components/ui/Header.tsx b/privy/react/src/components/ui/Header.tsx similarity index 100% rename from privy/react-privy-light-token/src/components/ui/Header.tsx rename to privy/react/src/components/ui/Header.tsx 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..1ab28b0 --- /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!.amount).toBe('0'); + 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..f087887 --- /dev/null +++ b/privy/react/src/hooks/__tests__/mock-rpc.ts @@ -0,0 +1,47 @@ +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'), + }; +} + +/** 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..42c8257 --- /dev/null +++ b/privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts @@ -0,0 +1,207 @@ +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'); + +vi.mock('@lightprotocol/compressed-token', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_LIGHT_ATA, +})); + +// 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: [] }, + }); +}); + +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', 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!.amount).toBe('2500000000'); + expect(sol!.decimals).toBe(9); + expect(sol!.isLightToken).toBe(false); + }); + + 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: [] }); + mockRpc.getAccountInfo.mockResolvedValue(null); + + 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!.amount).toBe('500000'); + expect(spl!.isLightToken).toBe(false); + expect(spl!.isNative).toBe(false); + }); + + it('identifies light-token ATA in T22 accounts', 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 } }], + }); + mockRpc.getAccountInfo.mockResolvedValue(null); + + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const lightToken = result.current.balances.find((b) => b.isLightToken); + expect(lightToken).toBeDefined(); + expect(lightToken!.amount).toBe('1000000'); + }); + + it('aggregates cold balance into existing hot light-token balance', async () => { + const mint = PublicKey.unique(); + const hotData = buildTokenAccountData(mint, 300_000n); + + // T22 account is a light-token ATA with 300k + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ + value: [{ pubkey: MOCK_LIGHT_ATA, account: { data: hotData } }], + }); + mockRpc.getAccountInfo.mockResolvedValue(null); + + // Compressed (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 lightToken = result.current.balances.find( + (b) => b.mint === mint.toBase58() && b.isLightToken, + ); + expect(lightToken).toBeDefined(); + // 300k hot + 200k cold = 500k + expect(lightToken!.amount).toBe('500000'); + }); + + 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!.amount).toBe('750000'); + expect(cold!.isLightToken).toBe(true); + }); + + 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); + // isLoading should be true during the fetch + 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); + }); + + // SOL fetch fails silently, so we get empty (no SOL entry) + // but the hook doesn't throw — it returns whatever it collected + expect(result.current.isLoading).toBe(false); + }); +}); \ No newline at end of file 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..3abb7a0 --- /dev/null +++ b/privy/react/src/hooks/__tests__/useTransfer.test.ts @@ -0,0 +1,161 @@ +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 MOCK_ATA = PublicKey.unique(); +const dummyIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), +}); + +vi.mock('@lightprotocol/compressed-token', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_ATA, + createTransferInterfaceInstruction: vi.fn(() => dummyIx), +})); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + getAtaInterface: vi.fn().mockRejectedValue(new Error('No ATA')), + createLoadAtaInstructionsFromInterface: vi.fn().mockResolvedValue([]), +})); + +// Need to mock this so the type import resolves +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +import { useTransfer } from '../useTransfer'; +import { + getAtaInterface, + createLoadAtaInstructionsFromInterface, +} from '@lightprotocol/compressed-token/unified'; + +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: no ATA (skip load) + vi.mocked(getAtaInterface).mockRejectedValue(new Error('No ATA')); +}); + +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(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('auto-loads cold balance when ATA exists', async () => { + const loadIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), + }); + + vi.mocked(getAtaInterface).mockResolvedValue({} as any); + vi.mocked(createLoadAtaInstructionsFromInterface).mockResolvedValue([ + loadIx, + ]); + + 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(getAtaInterface).toHaveBeenCalled(); + expect(createLoadAtaInstructionsFromInterface).toHaveBeenCalled(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalled(); + }); + + 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..46e03aa --- /dev/null +++ b/privy/react/src/hooks/__tests__/useUnwrap.test.ts @@ -0,0 +1,157 @@ +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 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', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_LIGHT_ATA, + getSplInterfaceInfos: (...args: unknown[]) => + mockGetSplInterfaceInfos(...args), +})); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + createUnwrapInstruction: vi.fn(() => dummyIx), +})); + +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +// Partial mock: keep real exports, 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 { useUnwrap } from '../useUnwrap'; +import { TokenAccountNotFoundError } from '@solana/spl-token'; + +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), + }); + mockGetSplInterfaceInfos.mockResolvedValue([ + { isInitialized: true, tokenProgram: TOKEN_2022_PROGRAM_ID }, + ]); + // Default: SPL ATA exists + mockGetAccount.mockResolvedValue({ amount: 0n }); +}); + +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: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 2, + decimals: 9, + }, + wallet, + signTransaction, + }); + }); + + expect(sig).toBe('tx-sig-unwrap'); + expect(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('creates SPL ATA when it does not exist', async () => { + mockGetAccount.mockRejectedValue( + new TokenAccountNotFoundError('not found', PublicKey.unique()), + ); + + const { result } = renderHook(() => useUnwrap()); + + let sig: string | undefined; + await act(async () => { + sig = await result.current.unwrap({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + + // Should still succeed — ATA creation instruction is added to the tx + expect(sig).toBe('tx-sig-unwrap'); + expect(mockRpc.sendRawTransaction).toHaveBeenCalled(); + }); + + it('throws when no SPL interface is found', async () => { + mockGetSplInterfaceInfos.mockResolvedValue([]); + + const { result } = renderHook(() => useUnwrap()); + + await expect( + act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }), + ).rejects.toThrow('No SPL interface found'); + }); + + it('clears isLoading after completion', async () => { + const { result } = renderHook(() => useUnwrap()); + + await act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: PublicKey.unique().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..201a29c --- /dev/null +++ b/privy/react/src/hooks/__tests__/useWrap.test.ts @@ -0,0 +1,161 @@ +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', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_LIGHT_ATA, + getSplInterfaceInfos: (...args: unknown[]) => + mockGetSplInterfaceInfos(...args), + createWrapInstruction: vi.fn(() => dummyIx), + createAssociatedTokenAccountInterfaceInstruction: 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-privy-light-token/src/hooks/index.ts b/privy/react/src/hooks/index.ts similarity index 100% rename from privy/react-privy-light-token/src/hooks/index.ts rename to privy/react/src/hooks/index.ts diff --git a/privy/react-privy-light-token/src/hooks/useLightTokenBalances.ts b/privy/react/src/hooks/useLightTokenBalances.ts similarity index 73% rename from privy/react-privy-light-token/src/hooks/useLightTokenBalances.ts rename to privy/react/src/hooks/useLightTokenBalances.ts index c4c6060..53f06ab 100644 --- a/privy/react-privy-light-token/src/hooks/useLightTokenBalances.ts +++ b/privy/react/src/hooks/useLightTokenBalances.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { PublicKey } from '@solana/web3.js'; import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; import { createRpc } from '@lightprotocol/stateless.js'; +import { getAssociatedTokenAddressInterface } from '@lightprotocol/compressed-token'; export interface TokenBalance { mint: string; @@ -24,7 +25,7 @@ export function useLightTokenBalances() { const owner = new PublicKey(ownerAddress); const allBalances: TokenBalance[] = []; - // Get regular SOL balance (display only, no transfer action) + // SOL balance try { const solBalance = await rpc.getBalance(owner); allBalances.push({ @@ -38,7 +39,7 @@ export function useLightTokenBalances() { // Failed to get SOL balance } - // Get regular SPL token accounts (Token Program) + // SPL balance (standard token program accounts) try { const tokenAccounts = await rpc.getTokenAccountsByOwner(owner, { programId: new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), @@ -76,16 +77,16 @@ export function useLightTokenBalances() { } } } catch { - // Failed to get SPL token accounts + // No SPL accounts } - // Get light-token ATAs (Token-2022 program accounts) + // T22 balance (Token-2022 accounts) try { const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { programId: TOKEN_2022_PROGRAM_ID, }); - for (const { account } of t22Accounts.value) { + for (const { pubkey, account } of t22Accounts.value) { const data = account.data; if (data instanceof Buffer || (typeof data === 'object' && 'length' in data)) { const dataBuffer = Buffer.from(data as Uint8Array); @@ -93,6 +94,9 @@ export function useLightTokenBalances() { const mint = new PublicKey(dataBuffer.subarray(0, 32)); const amount = dataBuffer.readBigUInt64LE(64); + const expectedAta = getAssociatedTokenAddressInterface(mint, owner); + const isLightToken = pubkey.equals(expectedAta); + try { const mintInfo = await rpc.getAccountInfo(mint); const decimals = mintInfo?.data ? (mintInfo.data as Buffer)[44] || 9 : 9; @@ -101,7 +105,7 @@ export function useLightTokenBalances() { mint: mint.toBase58(), amount: amount.toString(), decimals, - isLightToken: true, + isLightToken, isNative: false, }); } catch { @@ -109,7 +113,7 @@ export function useLightTokenBalances() { mint: mint.toBase58(), amount: amount.toString(), decimals: 9, - isLightToken: true, + isLightToken, isNative: false, }); } @@ -117,7 +121,33 @@ export function useLightTokenBalances() { } } } catch { - // Failed to get T22 token accounts + // No T22 accounts + } + + // Cold balance (compressed token accounts) + try { + const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner); + for (const item of compressed.value.items) { + const mintStr = item.mint.toBase58(); + const existing = allBalances.find( + (b) => b.mint === mintStr && b.isLightToken, + ); + if (existing) { + // Sum cold into hot balance + const total = BigInt(existing.amount) + BigInt(item.balance.toString()); + existing.amount = total.toString(); + } else { + allBalances.push({ + mint: mintStr, + amount: item.balance.toString(), + decimals: 9, + isLightToken: true, + isNative: false, + }); + } + } + } catch { + // No compressed accounts } setBalances(allBalances); diff --git a/privy/react-privy-light-token/src/hooks/useTransactionHistory.ts b/privy/react/src/hooks/useTransactionHistory.ts similarity index 100% rename from privy/react-privy-light-token/src/hooks/useTransactionHistory.ts rename to privy/react/src/hooks/useTransactionHistory.ts diff --git a/privy/react-privy-light-token/src/hooks/useTransfer.ts b/privy/react/src/hooks/useTransfer.ts similarity index 62% rename from privy/react-privy-light-token/src/hooks/useTransfer.ts rename to privy/react/src/hooks/useTransfer.ts index 4156d63..8d6df92 100644 --- a/privy/react-privy-light-token/src/hooks/useTransfer.ts +++ b/privy/react/src/hooks/useTransfer.ts @@ -3,8 +3,13 @@ import { PublicKey, Transaction } from '@solana/web3.js'; import { createTransferInterfaceInstruction, getAssociatedTokenAddressInterface, + createAssociatedTokenAccountInterfaceIdempotentInstruction, } from '@lightprotocol/compressed-token'; -import { createRpc } from '@lightprotocol/stateless.js'; +import { + getAtaInterface, + createLoadAtaInstructionsFromInterface, +} 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'; @@ -41,43 +46,56 @@ export function useTransfer() { const recipient = new PublicKey(toAddress); const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); - // Derive light-token ATAs (no account lookups or proofs needed) + // Derive light-token associated token accounts const sourceAta = getAssociatedTokenAddressInterface(mintPubkey, owner); const destAta = getAssociatedTokenAddressInterface(mintPubkey, recipient); - // Build transfer instruction - const transferIx = createTransferInterfaceInstruction( - sourceAta, - destAta, - owner, - tokenAmount, + // Build transaction + const transaction = new Transaction(); + + // Ensure destination associated token account exists (idempotent) + transaction.add( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + owner, + destAta, + recipient, + mintPubkey, + CTOKEN_PROGRAM_ID, + ), + ); + + // Consolidate cold + SPL + T22 into light associated token account if needed + try { + const ataInfo = await getAtaInterface(rpc, sourceAta, owner, mintPubkey); + const loadIxs = await createLoadAtaInstructionsFromInterface( + rpc, owner, ataInfo, undefined, true, + ); + if (loadIxs.length > 0) transaction.add(...loadIxs); + } catch { + // Associated token account doesn't exist yet + } + + // Transfer light-token associated token account → light-token associated token account + transaction.add( + createTransferInterfaceInstruction(sourceAta, destAta, owner, tokenAmount), ); - // Build transaction const { blockhash } = await rpc.getLatestBlockhash(); - const transaction = new Transaction(); - transaction.add(transferIx); transaction.recentBlockhash = blockhash; transaction.feePayer = owner; - // Serialize unsigned transaction const unsignedTxBuffer = transaction.serialize({ requireAllSignatures: false }); - - // Sign with Privy const signedTx = await signTransaction({ transaction: unsignedTxBuffer, wallet, chain: 'solana:devnet', }); - // Send transaction const signedTxBuffer = Buffer.from(signedTx.signedTransaction); - const signature = await rpc.sendRawTransaction(signedTxBuffer, { + return rpc.sendRawTransaction(signedTxBuffer, { skipPreflight: false, preflightCommitment: 'confirmed', }); - - return signature; } finally { setIsLoading(false); } diff --git a/privy/react-privy-light-token/src/hooks/useUnwrap.ts b/privy/react/src/hooks/useUnwrap.ts similarity index 87% rename from privy/react-privy-light-token/src/hooks/useUnwrap.ts rename to privy/react/src/hooks/useUnwrap.ts index 51efc51..5b616ce 100644 --- a/privy/react-privy-light-token/src/hooks/useUnwrap.ts +++ b/privy/react/src/hooks/useUnwrap.ts @@ -5,7 +5,6 @@ import { createAssociatedTokenAccountInstruction, getAccount, TokenAccountNotFoundError, - TOKEN_2022_PROGRAM_ID, } from '@solana/spl-token'; import { getAssociatedTokenAddressInterface, @@ -41,33 +40,35 @@ export function useUnwrap() { const { params, wallet, signTransaction } = args; const { ownerPublicKey, mint, amount, decimals = 9 } = params; + // Create RPC connection 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))); - // Source: light-token ATA - const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, owner); - - // Destination: SPL T22 ATA - const splAta = getAssociatedTokenAddressSync(mintPubkey, owner, false, TOKEN_2022_PROGRAM_ID); - - // Get SPL interface info + // 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; + + // Source: light-token associated token account + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, owner); + + // Destination: SPL/T22 associated token account (derived using the mint's token program) + const splAta = getAssociatedTokenAddressSync(mintPubkey, owner, false, tokenProgram); // Build transaction const { blockhash } = await rpc.getLatestBlockhash(); const transaction = new Transaction(); transaction.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 })); - // Create SPL T22 ATA if needed + // Create SPL/T22 associated token account if needed try { - await getAccount(rpc, splAta, undefined, TOKEN_2022_PROGRAM_ID); + await getAccount(rpc, splAta, undefined, tokenProgram); } catch (e) { if (e instanceof TokenAccountNotFoundError) { transaction.add( @@ -76,7 +77,7 @@ export function useUnwrap() { splAta, owner, mintPubkey, - TOKEN_2022_PROGRAM_ID, + tokenProgram, ), ); } else { diff --git a/privy/react-privy-light-token/src/hooks/useWrap.ts b/privy/react/src/hooks/useWrap.ts similarity index 86% rename from privy/react-privy-light-token/src/hooks/useWrap.ts rename to privy/react/src/hooks/useWrap.ts index 2c86db2..7cd462f 100644 --- a/privy/react-privy-light-token/src/hooks/useWrap.ts +++ b/privy/react/src/hooks/useWrap.ts @@ -5,8 +5,8 @@ import { createWrapInstruction, getAssociatedTokenAddressInterface, getSplInterfaceInfos, + createAssociatedTokenAccountInterfaceInstruction, } from '@lightprotocol/compressed-token'; -import { createAssociatedTokenAccountInterfaceInstruction } from '@lightprotocol/compressed-token'; 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'; @@ -36,27 +36,29 @@ export function useWrap() { const { params, wallet, signTransaction } = args; const { ownerPublicKey, mint, amount, decimals = 9 } = params; + // Create RPC connection 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))); - // Verify SPL ATA balance - const splAta = getAssociatedTokenAddressSync(mintPubkey, owner); - const ataAccount = await getAccount(rpc, splAta); - if (ataAccount.amount < BigInt(tokenAmount)) { - throw new Error('Insufficient SPL balance'); - } - - // Get SPL interface info for the mint + // 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 ATA + // Derive light-token associated token account const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, owner); // Build transaction @@ -64,7 +66,7 @@ export function useWrap() { const transaction = new Transaction(); transaction.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 })); - // Ensure light-token ATA exists (idempotent create) + // Ensure light-token associated token account exists (idempotent create) transaction.add( createAssociatedTokenAccountInterfaceInstruction( owner, diff --git a/privy/react-privy-light-token/src/index.css b/privy/react/src/index.css similarity index 100% rename from privy/react-privy-light-token/src/index.css rename to privy/react/src/index.css diff --git a/privy/react-privy-light-token/src/main.tsx b/privy/react/src/main.tsx similarity index 100% rename from privy/react-privy-light-token/src/main.tsx rename to privy/react/src/main.tsx 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-privy-light-token/src/vite-env.d.ts b/privy/react/src/vite-env.d.ts similarity index 100% rename from privy/react-privy-light-token/src/vite-env.d.ts rename to privy/react/src/vite-env.d.ts diff --git a/privy/react-privy-light-token/tsconfig.json b/privy/react/tsconfig.json similarity index 100% rename from privy/react-privy-light-token/tsconfig.json rename to privy/react/tsconfig.json diff --git a/privy/react-privy-light-token/tsconfig.node.json b/privy/react/tsconfig.node.json similarity index 100% rename from privy/react-privy-light-token/tsconfig.node.json rename to privy/react/tsconfig.node.json diff --git a/privy/react-privy-light-token/vite.config.ts b/privy/react/vite.config.ts similarity index 51% rename from privy/react-privy-light-token/vite.config.ts rename to privy/react/vite.config.ts index 604d69a..71ba862 100644 --- a/privy/react-privy-light-token/vite.config.ts +++ b/privy/react/vite.config.ts @@ -1,8 +1,15 @@ -import { defineConfig } from 'vite'; +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..dea59b1 --- /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:light-token [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:light-token 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..96bed52 --- /dev/null +++ b/privy/scripts/package.json @@ -0,0 +1,22 @@ +{ + "name": "privy-scripts", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "mint:light-token": "tsx src/mint-light-token.ts", + "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/nodejs/src/helpers/mint-light-token.ts b/privy/scripts/src/mint-light-token.ts similarity index 76% rename from privy/nodejs/src/helpers/mint-light-token.ts rename to privy/scripts/src/mint-light-token.ts index 25137bf..6dba0e7 100644 --- a/privy/nodejs/src/helpers/mint-light-token.ts +++ b/privy/scripts/src/mint-light-token.ts @@ -24,7 +24,7 @@ const createLightTokenMint = async ( ) => { const connection = createRpc(process.env.HELIUS_RPC_URL!); - // Load filesystem wallet (not Privy — helper script) + // Load filesystem wallet const payer = Keypair.fromSecretKey( new Uint8Array( JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) @@ -52,7 +52,7 @@ const createLightTokenMint = async ( const recipient = new PublicKey(recipientAddress); const tokenAmount = BigInt(initialAmount * Math.pow(10, decimals)); - // 1. Mint SPL/T22 tokens to payer's ATA (payer can sign) + // 1. Mint SPL/T22 tokens to payer's associated token account (payer can sign) const payerSplAta = await createAssociatedTokenAccount( connection, payer, @@ -74,13 +74,13 @@ const createLightTokenMint = async ( ); console.log('Minted', initialAmount, 'tokens'); - // 2. Wrap SPL/T22 → payer's light ATA + // 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 ATA'); + console.log('Wrapped into payer light associated token account'); - // 3. Transfer payer's light ATA → recipient's light ATA + // 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( @@ -104,7 +104,18 @@ const createLightTokenMint = async ( export default createLightTokenMint; // --- main --- -import {TREASURY_WALLET_ADDRESS} from '../config.js'; -createLightTokenMint(9, 100, TREASURY_WALLET_ADDRESS) +// Usage: npm run mint:light-token [recipient] [amount] [decimals] +// Falls back to RECIPIENT_ADDRESS env var. +const recipient = process.argv[2] || process.env.RECIPIENT_ADDRESS; +const amount = Number(process.argv[3] || 100); +const decimals = Number(process.argv[4] || 9); + +if (!recipient) { + console.error('Usage: npm run mint:light-token [amount] [decimals]'); + console.error(' or set RECIPIENT_ADDRESS in .env'); + process.exit(1); +} + +createLightTokenMint(decimals, amount, recipient) .then((result) => console.log('Mint result:', result)) .catch(console.error); diff --git a/privy/nodejs/src/helpers/mint-spl.ts b/privy/scripts/src/mint-spl.ts similarity index 74% rename from privy/nodejs/src/helpers/mint-spl.ts rename to privy/scripts/src/mint-spl.ts index e5625ae..0b52329 100644 --- a/privy/nodejs/src/helpers/mint-spl.ts +++ b/privy/scripts/src/mint-spl.ts @@ -32,14 +32,14 @@ const mintSplTokens = async ( const recipient = new PublicKey(recipientAddress); const tokenAmount = BigInt(amount * Math.pow(10, decimals)); - // Derive recipient ATA for the given token program (SPL or T22) + // 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 ATA if it doesn't exist + // Create associated token account if it doesn't exist try { await getAccount(connection, recipientAta, undefined, tokenProgramId); } catch (e) { @@ -78,7 +78,19 @@ const mintSplTokens = async ( export default mintSplTokens; // --- main --- -import {TREASURY_WALLET_ADDRESS, TEST_MINT} from '../config.js'; -mintSplTokens(TEST_MINT, TREASURY_WALLET_ADDRESS, 100, 9) +// 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/nodejs/src/helpers/register-spl-interface.ts b/privy/scripts/src/register-spl-interface.ts similarity index 76% rename from privy/nodejs/src/helpers/register-spl-interface.ts rename to privy/scripts/src/register-spl-interface.ts index 27dfbbe..5774218 100644 --- a/privy/nodejs/src/helpers/register-spl-interface.ts +++ b/privy/scripts/src/register-spl-interface.ts @@ -10,8 +10,7 @@ import {readFileSync} from 'fs'; const registerSplInterface = async (mintAddress: string, tokenProgramId?: PublicKey) => { const connection = createRpc(process.env.HELIUS_RPC_URL!); - - // Load filesystem wallet (not Privy — helper script) + // Load filesystem wallet const payer = Keypair.fromSecretKey( new Uint8Array( JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) @@ -31,7 +30,16 @@ export {registerSplInterface}; export default registerSplInterface; // --- main --- -import {TEST_MINT} from '../config.js'; -registerSplInterface(TEST_MINT) +// 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"] +} From 7a6f998d4b60419e0a3af6874e2706ffd4a8188b Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Sun, 15 Feb 2026 20:16:02 +0000 Subject: [PATCH 04/15] rm shared sig --- CLAUDE.md | 41 ++++++------ package.json | 2 +- privy/nodejs/src/load.ts | 68 +++++++++----------- privy/nodejs/src/transfer.ts | 90 +++++++++----------------- privy/nodejs/src/unwrap.ts | 120 +++++++++++------------------------ privy/nodejs/src/wrap.ts | 73 ++++++++------------- 6 files changed, 149 insertions(+), 245 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index acbd2be..323e10f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,11 +70,11 @@ 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 (use local filesystem keypair, not Privy) -npm run mint:light-token # 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 -npm run decompress # Decompress light-token ATA to T22 ATA +# Setup helpers live in privy/scripts/ (separate workspace, uses local keypair) +cd ../scripts +npm run mint:light-token # 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) @@ -102,7 +102,7 @@ curl http://127.0.0.1:8784/health ### Workspace layout -Root `package.json` defines npm workspaces: `typescript-client`, `toolkits/payments-and-wallets`, `privy/nodejs`, and `privy/react`. Dependencies are hoisted to root. +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 @@ -164,24 +164,29 @@ const payer = Keypair.fromSecretKey( ### 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`. +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**. -**Signing pattern**: Build unsigned `Transaction` → serialize with `requireAllSignatures: false` → sign via `privy.wallets().solana().signTransaction(walletId, { transaction, authorization_context })` → deserialize → `sendRawTransaction`. +**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