diff --git a/CLAUDE.md b/CLAUDE.md index eab9505..a67165d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ cargo test-sbf -p -- --test-threads=1 ### Privy Node.js (devnet) ```bash -cd privy/nodejs +cd toolkits/sign-with-privy/nodejs npm install cp .env.example .env # fill in Privy credentials + Helius RPC URL @@ -70,7 +70,7 @@ npm run load # Consolidate cold + SPL + T22 into light-token ATA npm run balances # Query balance breakdown (hot, cold, SPL/T22, SOL) npm run history # Transaction history for light-token interface ops -# Setup helpers live in privy/scripts/ (separate workspace, uses local keypair) +# Setup helpers live in toolkits/sign-with-privy/scripts/ (separate workspace, uses local keypair) cd ../scripts npm run mint:spl-and-wrap # Create mint + interface PDA + fund treasury npm run mint:spl # Mint SPL/T22 tokens to existing mint @@ -80,7 +80,7 @@ npm run register:spl-interface # Register interface PDA on existing min ### Privy React (devnet — WIP) ```bash -cd privy/react +cd toolkits/sign-with-privy/react npm install cp .env.example .env # fill in VITE_PRIVY_APP_ID and VITE_HELIUS_RPC_URL @@ -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`, `privy/react`, and `privy/scripts`. Dependencies are hoisted to root. +Root `package.json` defines npm workspaces: `typescript-client`, `toolkits/payments-and-wallets`, `toolkits/sign-with-privy/nodejs`, `toolkits/sign-with-privy/react`, and `toolkits/sign-with-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 @@ -113,9 +113,9 @@ Root `package.json` defines npm workspaces: `typescript-client`, `toolkits/payme - `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) + - `sign-with-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 @@ -162,7 +162,7 @@ const payer = Keypair.fromSecretKey( **CPI pattern** (`basic-instructions/`): Explicit CPI calls like `TransferInterfaceCpi`. -### Privy Node.js architecture (`privy/nodejs/`) +### Privy Node.js architecture (`toolkits/sign-with-privy/nodejs/`) Server-side scripts that sign transactions via Privy's wallet API instead of a local keypair. Each script in `src/` is standalone and runnable with `tsx`. All scripts target **devnet**. @@ -172,8 +172,8 @@ Server-side scripts that sign transactions via Privy's wallet API instead of a l **Two workspaces, two signing modes**: -- **App operations** (`privy/nodejs/src/*.ts`): Privy server wallet signing. Six scripts: `transfer`, `wrap`, `unwrap`, `load`, `balances`, `get-transaction-history`. -- **Setup helpers** (`privy/scripts/src/*.ts`): Separate npm workspace. Uses local filesystem keypair (`~/.config/solana/id.json`), not Privy. Scripts: `mint-spl-and-wrap`, `mint-spl`, `register-spl-interface`. +- **App operations** (`toolkits/sign-with-privy/nodejs/src/*.ts`): Privy server wallet signing. Six scripts: `transfer`, `wrap`, `unwrap`, `load`, `balances`, `get-transaction-history`. +- **Setup helpers** (`toolkits/sign-with-privy/scripts/src/*.ts`): Separate npm workspace. Uses local filesystem keypair (`~/.config/solana/id.json`), not Privy. Scripts: `mint-spl-and-wrap`, `mint-spl`, `register-spl-interface`. **Config**: All env vars centralized in `src/config.ts` with validation. Exports `TREASURY_WALLET_ID`, `TREASURY_WALLET_ADDRESS`, `TREASURY_AUTHORIZATION_KEY`, `HELIUS_RPC_URL`, `TEST_MINT`, and convenience defaults (`DEFAULT_TEST_RECIPIENT`, `DEFAULT_AMOUNT`, `DEFAULT_DECIMALS`). Scripts import from `./config.js` (ESM `.js` extension required). diff --git a/README.md b/README.md index aff931f..93c22c8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Light token is a high-performance token standard that reduces the cost of mint a |---------|-------------| | [Payments and Wallets](toolkits/payments-and-wallets/) | All you need for wallet integrations and payment flows. Minimal API differences to SPL. | | [Streaming Tokens](toolkits/streaming-tokens/) | Stream mint events using Laserstream | +| [Sign with Privy](toolkits/sign-with-privy/) | Light-token operations signed with Privy wallets (Node.js + React) | ## Client Examples diff --git a/package.json b/package.json index cbcbe7e..65f79ce 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "workspaces": [ "typescript-client", "toolkits/payments-and-wallets", - "privy/react", - "privy/nodejs", - "privy/scripts" + "toolkits/sign-with-privy/react", + "toolkits/sign-with-privy/nodejs", + "toolkits/sign-with-privy/scripts" ], "scripts": { "toolkit:payments": "npm run -w toolkits/payments-and-wallets" diff --git a/toolkits/README.md b/toolkits/README.md index 841d36b..62bad80 100644 --- a/toolkits/README.md +++ b/toolkits/README.md @@ -12,6 +12,13 @@ Your users hold and receive tokens of the same mints, just stored more efficient - **[wrap](payments-and-wallets/wrap.ts)** - Wrap SPL/T22 to light-token - **[unwrap](payments-and-wallets/unwrap.ts)** - Unwrap light-token to SPL/T22 +### Sign with Privy + +Light-token operations signed with [Privy](https://privy.io) wallets. Server-side (Node.js) and client-side (React) examples for transfer, wrap, unwrap, load, and balance queries on devnet. +- **[Node.js](sign-with-privy/nodejs/)** — Server-side scripts using `@privy-io/node` with server wallet signing +- **[React](sign-with-privy/react/)** — Browser app using `@privy-io/react-auth` with embedded wallet signing +- **[Setup scripts](sign-with-privy/scripts/)** — Create test mints and fund wallets on devnet + ### Streaming Tokens [Rust program example to stream mint events](streaming-tokens/) of the Light-Token Program. diff --git a/toolkits/sign-with-privy/CLAUDE.md b/toolkits/sign-with-privy/CLAUDE.md new file mode 100644 index 0000000..2cd29d7 --- /dev/null +++ b/toolkits/sign-with-privy/CLAUDE.md @@ -0,0 +1,142 @@ +# 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, unwrap, load, and balance query flows using Privy-managed wallets for transaction signing. + +## Sub-projects + +### `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/` — 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. + +## Build and run + +### Node.js scripts + +```bash +cd nodejs +npm install +cp .env.example .env # fill in all values + +# 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:spl-and-wrap # Create mint + interface PDA + fund treasury +npm run mint:spl # Mint SPL/T22 tokens to existing mint +npm run register:spl-interface # Register interface PDA on existing mint +npm run decompress # Decompress light-token ATA to T22 ATA +``` + +### React app + +```bash +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 +``` + +## 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. + +### 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-spl-and-wrap.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 (`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` + +## Environment variables + +### Node.js (`nodejs/.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 | 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 (`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, 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) +- `@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 calls `getSplInterfaceInfos` to find the initialized SPL interface and its `tokenProgram`. +- Unwrap imports from `@lightprotocol/compressed-token/unified` (not the main export). +- 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/toolkits/sign-with-privy/README.md b/toolkits/sign-with-privy/README.md new file mode 100644 index 0000000..1bcb1a8 --- /dev/null +++ b/toolkits/sign-with-privy/README.md @@ -0,0 +1,45 @@ +# Privy + Light Token + +Transfer, wrap, unwrap, and query light-tokens signed with Privy wallets. Learn more in the README of the respective examples. + +- **[Node.js](nodejs/)** — Server-side scripts using `@privy-io/node` with server wallet signing +- **[React](react/)** — Browser app using `@privy-io/react-auth` with embedded wallet signing +- **[Setup scripts](scripts/)** — Create test mints and fund wallets on devnet (local keypair, no Privy needed) + +| Creation cost | SPL | Light Token | +| :---------------- | :------------------ | :------------------- | +| **Token account** | ~2,000,000 lamports | ~**11,000** lamports | + +Privy handles user authentication and wallet management. You build transactions with light-token and Privy signs them: + +1. Authenticate with Privy +2. Build unsigned transaction with light-token instructions +3. Sign transaction using Privy's wallet provider +4. Send signed transaction to RPC + +## Operations + +| | SPL | Light Token | +| --- | --- | --- | +| **Transfer** | `createTransferInstruction()` | `createTransferInterfaceInstructions()` | +| **Receive / Load** | `getOrCreateAssociatedTokenAccount()` | `createLoadAtaInstructions()` | +| **Wrap (SPL → Light)** | N/A | `createWrapInstruction()` | +| **Unwrap (Light → SPL)** | N/A | `createUnwrapInstructions()` | +| **Get balance** | `getAccount()` | `getAtaInterface()` | +| **Transaction history** | `getSignaturesForAddress()` | `getSignaturesForOwnerInterface()` | + +### Transfer and Loading Balance + +Light Token accounts exist in two states: **hot** (active on-chain with rent-exempt balance) and **cold** (compressed after extended inactivity, `is_initialized: false`). Programs interact only with hot accounts. + +`loadAta` reinstates a cold account back to active on-chain state. It unifies balances from compressed tokens, SPL, and Token 2022 into a single Light Token associated token account. Creates the ATA if it doesn't exist. Returns `null` if there's nothing to load (idempotent). + +`transfer` and `unwrap` auto-load before executing — explicit `loadAta` is only needed when receiving payments. + +APIs return `TransactionInstruction[][]` — each inner array is one transaction. Almost always one. The same loop handles multi-transaction cases. + +## Documentation + +- [Light Token with Privy wallets](https://www.zkcompression.com/light-token/toolkits/for-privy) +- [Toolkit for stablecoin payments](https://www.zkcompression.com/light-token/toolkits/for-payments) +- [Light Token overview](https://www.zkcompression.com/light-token/welcome) diff --git a/toolkits/sign-with-privy/nodejs/.env.example b/toolkits/sign-with-privy/nodejs/.env.example new file mode 100644 index 0000000..81933c5 --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/.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/toolkits/sign-with-privy/nodejs/.gitignore b/toolkits/sign-with-privy/nodejs/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/toolkits/sign-with-privy/nodejs/README.md b/toolkits/sign-with-privy/nodejs/README.md new file mode 100644 index 0000000..485a843 --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/README.md @@ -0,0 +1,149 @@ +# 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 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. + +> 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 (from scripts/) +cd ../scripts && npm run register:spl-interface +``` + +Or in code via `createSplInterface(rpc, payer, mint)`. Works with both SPL Token and Token-2022 mints. + +**Example: wrapping devnet USDC.** If you have devnet USDC (`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`), register its interface PDA first, then wrap it into a light-token associated token account. Set `TEST_MINT` in `.env` to the USDC mint address. + +### 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 associated token account) +const { parsed, isCold } = await getAtaInterface(rpc, ata, owner, mint); + +// Cold balance (compressed tokens not yet loaded into associated token account) +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 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) + +Setup scripts live in [`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:spl-and-wrap [amount] [decimals]` | Create an SPL or T22 mint with interface PDA, mint tokens, wrap, and transfer to recipient. | +| `npm run mint:spl [amount] [decimals]` | Mint additional SPL or T22 tokens to an existing mint. | +| `npm run register:spl-interface ` | Register an interface PDA on an existing SPL or T22 mint. Required for wrap/unwrap. | + +## Quick start + +Run these in order to see the full flow on devnet: + +```bash +# 1. Create a test mint with interface PDA + fund the treasury wallet +cd ../scripts && npm run mint:spl-and-wrap + +# 2. Check balances — should show 100 tokens in hot balance +cd ../nodejs && 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 associated token account +npm run wrap +``` diff --git a/toolkits/sign-with-privy/nodejs/package.json b/toolkits/sign-with-privy/nodejs/package.json new file mode 100644 index 0000000..0b29d3a --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/package.json @@ -0,0 +1,24 @@ +{ + "name": "nodejs-privy-light-token", + "version": "1.0.0", + "type": "module", + "scripts": { + "transfer": "tsx src/transfer.ts", + "wrap": "tsx src/wrap.ts", + "unwrap": "tsx src/unwrap.ts", + "balances": "tsx src/balances.ts", + "history": "tsx src/get-transaction-history.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/toolkits/sign-with-privy/nodejs/src/balances.ts b/toolkits/sign-with-privy/nodejs/src/balances.ts new file mode 100644 index 0000000..4643b02 --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/src/balances.ts @@ -0,0 +1,169 @@ +import 'dotenv/config'; +import {PublicKey, LAMPORTS_PER_SOL} from '@solana/web3.js'; +import {TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID} from '@solana/spl-token'; +import {createRpc} from '@lightprotocol/stateless.js'; +import { + getAtaInterface, + getAssociatedTokenAddressInterface, +} from '@lightprotocol/compressed-token/unified'; + +interface TokenBalance { + mint: string; + decimals: number; + hot: number; + cold: number; + spl: number; + t22: number; + unified: number; +} + +interface BalanceBreakdown { + sol: number; + tokens: TokenBalance[]; +} + +export async function getBalances( + ownerAddress: string, +): Promise { + const rpc = createRpc(process.env.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); + } + + // Per-mint accumulator + const mintMap = new Map(); + + const getOrCreate = (mintStr: string) => { + let entry = mintMap.get(mintStr); + if (!entry) { + entry = {spl: 0, t22: 0, hot: 0, cold: 0, decimals: 9}; + mintMap.set(mintStr, entry); + } + return entry; + }; + + // Track Light Token associated token accounts so we don't double-count them as Token 2022 balances + const lightAtaMints = new Set(); + + // 1. SPL accounts (standard token program) + try { + const splAccounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_PROGRAM_ID, + }); + for (const {account} of splAccounts.value) { + const buf = toBuffer(account.data); + if (!buf || buf.length < 72) continue; + const mint = new PublicKey(buf.subarray(0, 32)); + const amount = buf.readBigUInt64LE(64); + const mintStr = mint.toBase58(); + getOrCreate(mintStr).spl += toUiAmount(amount, 9); + } + } catch { + // No SPL accounts + } + + // 2. T22 accounts (Token-2022) — skip Light Token associated token accounts + try { + const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_2022_PROGRAM_ID, + }); + for (const {pubkey, account} of t22Accounts.value) { + const buf = toBuffer(account.data); + if (!buf || buf.length < 72) continue; + const mint = new PublicKey(buf.subarray(0, 32)); + const mintStr = mint.toBase58(); + const expectedAta = getAssociatedTokenAddressInterface(mint, owner); + if (pubkey.equals(expectedAta)) { + // Light Token associated token account — query via getAtaInterface instead + lightAtaMints.add(mintStr); + getOrCreate(mintStr); + } else { + const amount = buf.readBigUInt64LE(64); + getOrCreate(mintStr).t22 += toUiAmount(amount, 9); + } + } + } catch { + // No Token 2022 accounts + } + + // 3. Hot balance via getAtaInterface (parallelize per mint) + const mintKeys = [...mintMap.keys()]; + await Promise.allSettled( + mintKeys.map(async (mintStr) => { + try { + const mint = new PublicKey(mintStr); + const ata = getAssociatedTokenAddressInterface(mint, owner); + const {parsed} = await getAtaInterface(rpc, ata, owner, mint); + getOrCreate(mintStr).hot = toUiAmount(parsed.amount, 9); + } catch { + // Associated token account does not exist for this mint + } + }), + ); + + // 4. Cold balance (compressed token accounts — all mints at once) + try { + const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner); + for (const item of compressed.value.items) { + const mintStr = item.mint.toBase58(); + getOrCreate(mintStr).cold += toUiAmount(BigInt(item.balance.toString()), 9); + } + } catch { + // No compressed accounts + } + + // Assemble result + const tokens: TokenBalance[] = []; + for (const [mintStr, entry] of mintMap) { + tokens.push({ + mint: mintStr, + decimals: entry.decimals, + hot: entry.hot, + cold: entry.cold, + spl: entry.spl, + t22: entry.t22, + unified: entry.hot + entry.cold, + }); + } + + return {sol: solLamports / LAMPORTS_PER_SOL, tokens}; +} + +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; +} + +export default getBalances; + +// --- main --- +import {TREASURY_WALLET_ADDRESS} from './config.js'; +getBalances(TREASURY_WALLET_ADDRESS) + .then((b) => { + console.log(`SOL: ${b.sol}`); + if (b.tokens.length === 0) { + console.log('No token balances found.'); + return; + } + for (const t of b.tokens) { + const short = t.mint.slice(0, 6) + '...'; + console.log(`\nToken ${short} (unified): ${t.unified}`); + console.log(` Hot (Light Token associated token account): ${t.hot}`); + console.log(` Cold (compressed): ${t.cold}`); + console.log(` SPL: ${t.spl}`); + console.log(` T22: ${t.t22}`); + } + }) + .catch(console.error); diff --git a/toolkits/sign-with-privy/nodejs/src/config.ts b/toolkits/sign-with-privy/nodejs/src/config.ts new file mode 100644 index 0000000..4750db0 --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/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 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'; + +// 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/toolkits/sign-with-privy/nodejs/src/get-transaction-history.ts b/toolkits/sign-with-privy/nodejs/src/get-transaction-history.ts new file mode 100644 index 0000000..21b71db --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/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/toolkits/sign-with-privy/nodejs/src/transfer.ts b/toolkits/sign-with-privy/nodejs/src/transfer.ts new file mode 100644 index 0000000..bbf908f --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/src/transfer.ts @@ -0,0 +1,76 @@ +import 'dotenv/config'; +import {PrivyClient} from '@privy-io/node'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {PublicKey, Transaction} from '@solana/web3.js'; +import { + createTransferInterfaceInstructions, +} from '@lightprotocol/compressed-token/unified'; + +const transferLightTokens = async ( + fromAddress: string, + toAddress: 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 toPubkey = new PublicKey(toAddress); + const mintPubkey = new PublicKey(tokenMintAddress); + const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); + + // Loads cold (compressed), SPL, and Token 2022 balances into the Light Token associated token account before transfer. + // Returns TransactionInstruction[][] — send [0..n-2] in parallel, then [n-1] last. + const instructions = await createTransferInterfaceInstructions( + connection, fromPubkey, mintPubkey, tokenAmount, fromPubkey, toPubkey, + ); + + // Sign and send each batch via Privy + const walletId = process.env.TREASURY_WALLET_ID!; + const authorizationKey = process.env.TREASURY_AUTHORIZATION_KEY!; + const signatures: string[] = []; + + for (const ixs of instructions) { + const tx = new Transaction().add(...ixs); + const {blockhash} = await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = fromPubkey; + + const {signed_transaction} = await privy.wallets().solana().signTransaction( + walletId, { + transaction: tx.serialize({requireAllSignatures: false}), + authorization_context: {authorization_private_keys: [authorizationKey]}, + }, + ) as any; + + const sig = await connection.sendRawTransaction( + Buffer.from(signed_transaction, 'base64'), + {skipPreflight: false, preflightCommitment: 'confirmed'}, + ); + await connection.confirmTransaction(sig, 'confirmed'); + signatures.push(sig); + } + + return signatures[signatures.length - 1]; +}; + +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/toolkits/sign-with-privy/nodejs/src/unwrap.ts b/toolkits/sign-with-privy/nodejs/src/unwrap.ts new file mode 100644 index 0000000..b31f1d5 --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/src/unwrap.ts @@ -0,0 +1,82 @@ +import 'dotenv/config'; +import {PrivyClient} from '@privy-io/node'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {PublicKey, Transaction} from '@solana/web3.js'; +import {getAssociatedTokenAddressSync} from '@solana/spl-token'; +import { + createUnwrapInstructions, +} 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))); + + // Auto-detect token program (SPL vs Token 2022) from mint account owner + const mintAccountInfo = await connection.getAccountInfo(mintPubkey); + if (!mintAccountInfo) throw new Error(`Mint account ${tokenMintAddress} not found`); + const tokenProgramId = mintAccountInfo.owner; + + // Destination: SPL/T22 associated token account + const splAta = getAssociatedTokenAddressSync(mintPubkey, fromPubkey, false, tokenProgramId); + + // Returns TransactionInstruction[][]. + // Each inner array is one transaction. + // Handles loading + unwrapping together. + const instructions = await createUnwrapInstructions( + connection, splAta, fromPubkey, mintPubkey, tokenAmount, fromPubkey, + ); + + // Sign and send each batch via Privy + const walletId = process.env.TREASURY_WALLET_ID!; + const authorizationKey = process.env.TREASURY_AUTHORIZATION_KEY!; + const signatures: string[] = []; + + for (const ixs of instructions) { + const tx = new Transaction().add(...ixs); + const {blockhash} = await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = fromPubkey; + + const {signed_transaction} = await privy.wallets().solana().signTransaction( + walletId, { + transaction: tx.serialize({requireAllSignatures: false}), + authorization_context: {authorization_private_keys: [authorizationKey]}, + }, + ) as any; + + const sig = await connection.sendRawTransaction( + Buffer.from(signed_transaction, 'base64'), + {skipPreflight: false, preflightCommitment: 'confirmed'}, + ); + await connection.confirmTransaction(sig, 'confirmed'); + signatures.push(sig); + } + + return signatures[signatures.length - 1]; +}; + +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/toolkits/sign-with-privy/nodejs/src/wrap.ts b/toolkits/sign-with-privy/nodejs/src/wrap.ts new file mode 100644 index 0000000..1b49417 --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/src/wrap.ts @@ -0,0 +1,95 @@ +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} from '@solana/spl-token'; +import {getSplInterfaceInfos} from '@lightprotocol/compressed-token'; +import { + createWrapInstruction, + getAssociatedTokenAddressInterface, + createAssociatedTokenAccountInterfaceIdempotentInstruction, +} from '@lightprotocol/compressed-token/unified'; + +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))); + + // Get SPL interface info — determines whether mint uses SPL or Token 2022 + 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 associated token account using the mint's token program (SPL or Token 2022) + 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 associated token account + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, fromPubkey); + + // Build instructions + const tx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitLimit({units: 200_000}), + createAssociatedTokenAccountInterfaceIdempotentInstruction( + fromPubkey, lightTokenAta, fromPubkey, mintPubkey, CTOKEN_PROGRAM_ID, + ), + createWrapInstruction( + splAta, lightTokenAta, fromPubkey, mintPubkey, + tokenAmount, splInterfaceInfo, decimals, fromPubkey, + ), + ); + + // Sign and send via Privy + const {blockhash} = await connection.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = fromPubkey; + + const walletId = process.env.TREASURY_WALLET_ID!; + const authorizationKey = process.env.TREASURY_AUTHORIZATION_KEY!; + + const {signed_transaction} = await privy.wallets().solana().signTransaction( + walletId, { + transaction: tx.serialize({requireAllSignatures: false}), + authorization_context: {authorization_private_keys: [authorizationKey]}, + }, + ) as any; + + const signature = await connection.sendRawTransaction( + Buffer.from(signed_transaction, 'base64'), + {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/toolkits/sign-with-privy/nodejs/tsconfig.json b/toolkits/sign-with-privy/nodejs/tsconfig.json new file mode 100644 index 0000000..36b5ba4 --- /dev/null +++ b/toolkits/sign-with-privy/nodejs/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/toolkits/sign-with-privy/react/.env.example b/toolkits/sign-with-privy/react/.env.example new file mode 100644 index 0000000..da8d1d2 --- /dev/null +++ b/toolkits/sign-with-privy/react/.env.example @@ -0,0 +1,2 @@ +VITE_PRIVY_APP_ID= +VITE_HELIUS_RPC_URL= diff --git a/toolkits/sign-with-privy/react/README.md b/toolkits/sign-with-privy/react/README.md new file mode 100644 index 0000000..34e0895 --- /dev/null +++ b/toolkits/sign-with-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 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 [`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:spl-and-wrap [amount] [decimals]` | Create an SPL or T22 mint with interface PDA, mint tokens, wrap, and transfer to recipient. | +| `npm run mint:spl [amount] [decimals]` | Mint additional SPL or T22 tokens to an existing mint. | +| `npm run register:spl-interface ` | Register an interface PDA on an existing SPL or T22 mint. Required for wrap/unwrap. | + +## Quick start + +```bash +# 1. Create a test mint with interface PDA + fund your Privy wallet +cd ../scripts && npm run mint:spl-and-wrap + +# 2. Start the dev server +cd ../react && npm run dev +``` + +Then in the browser: +1. Connect your wallet via 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/toolkits/sign-with-privy/react/index.html b/toolkits/sign-with-privy/react/index.html new file mode 100644 index 0000000..89231aa --- /dev/null +++ b/toolkits/sign-with-privy/react/index.html @@ -0,0 +1,13 @@ + + + + + + + React Privy Light Token + + +
+ + + diff --git a/toolkits/sign-with-privy/react/package.json b/toolkits/sign-with-privy/react/package.json new file mode 100644 index 0000000..52dc965 --- /dev/null +++ b/toolkits/sign-with-privy/react/package.json @@ -0,0 +1,39 @@ +{ + "name": "react-privy-light-token", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts" + }, + "dependencies": { + "@heroicons/react": "^2.2.0", + "@lightprotocol/compressed-token": "beta", + "@lightprotocol/stateless.js": "beta", + "@privy-io/react-auth": "^3.9.1", + "@solana-program/memo": "^0.10.0", + "@solana/kit": "^5.5.1", + "@solana/spl-token": "^0.4.13", + "@solana/web3.js": "1.98.4", + "buffer": "^6.0.3", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.1.1", + "@types/react-dom": "^19.1.1", + "@vitejs/plugin-react": "^4.3.4", + "happy-dom": "^20.6.1", + "tailwindcss": "^4.1.18", + "typescript": "^5.8.3", + "vite": "^6.0.11", + "vite-plugin-node-polyfills": "^0.25.0", + "vitest": "^4.0.18" + } +} diff --git a/toolkits/sign-with-privy/react/src/App.tsx b/toolkits/sign-with-privy/react/src/App.tsx new file mode 100644 index 0000000..ab9dd47 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/App.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { usePrivy } from '@privy-io/react-auth'; +import { useWallets } from '@privy-io/react-auth/solana'; +import { useLightTokenBalances } from './hooks/useLightTokenBalances'; +import { Header } from './components/ui/Header'; +import WalletInfo from './components/sections/WalletInfo'; +import TransferForm from './components/sections/TransferForm'; +import TransactionStatus from './components/sections/TransactionStatus'; +import TransactionHistory from './components/sections/TransactionHistory'; +import { ArrowLeftIcon, ClipboardIcon } from '@heroicons/react/24/outline'; + +export default function App() { + const { login, logout, authenticated, user } = usePrivy(); + const { wallets } = useWallets(); + const { balances, isLoading: isLoadingBalances, fetchBalances } = useLightTokenBalances(); + + const [selectedWallet, setSelectedWallet] = useState(''); + const [selectedMint, setSelectedMint] = useState(''); + const [txSignature, setTxSignature] = useState(null); + const [txError, setTxError] = useState(null); + + useEffect(() => { + if (wallets.length > 0 && !selectedWallet) { + setSelectedWallet(wallets[0].address); + } + }, [wallets, selectedWallet]); + + useEffect(() => { + if (!selectedWallet) { + setSelectedMint(''); + return; + } + + const loadBalances = async () => { + await fetchBalances(selectedWallet); + }; + + loadBalances(); + }, [selectedWallet, fetchBalances]); + + useEffect(() => { + if (balances.length > 0 && !selectedMint) { + setSelectedMint(balances[0].mint); + } + }, [balances, selectedMint]); + + const handleWalletChange = (address: string) => { + setSelectedWallet(address); + setSelectedMint(''); + }; + + const handleTransferSuccess = async (signature: string) => { + setTxSignature(signature); + setTxError(null); + await fetchBalances(selectedWallet); + }; + + const handleTransferError = (error: string) => { + setTxError(error); + setTxSignature(null); + }; + + const copyAddress = () => { + if (user?.wallet?.address) { + navigator.clipboard.writeText(user.wallet.address); + alert('Address copied!'); + } + }; + + return ( +
+
+ {authenticated ? ( +
+
+ + {user?.wallet?.address && ( + + )} +
+ +
+
+ + + + + + + +
+
+
+ ) : ( +
+
+

+ Send Tokens +

+

+ Send light tokens to any Solana address instantly. +

+ +
+
+ )} +
+ ); +} diff --git a/toolkits/sign-with-privy/react/src/components/reusables/CopyButton.tsx b/toolkits/sign-with-privy/react/src/components/reusables/CopyButton.tsx new file mode 100644 index 0000000..71258b3 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/components/reusables/CopyButton.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { ClipboardIcon, CheckIcon } from '@heroicons/react/24/outline'; + +interface CopyButtonProps { + text: string; + label?: string; +} + +export default function CopyButton({ text, label }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/toolkits/sign-with-privy/react/src/components/reusables/Section.tsx b/toolkits/sign-with-privy/react/src/components/reusables/Section.tsx new file mode 100644 index 0000000..2726631 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/components/reusables/Section.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; + +interface SectionProps { + name: string; + children: ReactNode; +} + +export default function Section({ name, children }: SectionProps) { + return ( +
+

{name}

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

Success!

+

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

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

Error

+

{error}

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

Address

+

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

+
+ +
+
+ ); +} diff --git a/toolkits/sign-with-privy/react/src/components/ui/Header.tsx b/toolkits/sign-with-privy/react/src/components/ui/Header.tsx new file mode 100644 index 0000000..0ea9234 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/components/ui/Header.tsx @@ -0,0 +1,30 @@ +export function Header() { + return ( +
+
+
+ Light Token + + Privy +
+ +
+
+ ); +} diff --git a/toolkits/sign-with-privy/react/src/hooks/__tests__/integration/hooks.integration.test.ts b/toolkits/sign-with-privy/react/src/hooks/__tests__/integration/hooks.integration.test.ts new file mode 100644 index 0000000..ae632e1 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/__tests__/integration/hooks.integration.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { renderHook, act } from '@testing-library/react'; +import { useLightTokenBalances } from '../../useLightTokenBalances'; +import { useTransactionHistory } from '../../useTransactionHistory'; + +const RPC_URL = import.meta.env.VITE_HELIUS_RPC_URL; + +// Fresh keypair — no token accounts, minimal RPC calls +const TEST_ADDRESS = Keypair.generate().publicKey.toBase58(); + +describe.runIf(RPC_URL)('hooks (devnet integration)', () => { + describe('useLightTokenBalances', () => { + it('fetches SOL balance for empty wallet', async () => { + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(TEST_ADDRESS); + }); + + expect(result.current.isLoading).toBe(false); + expect(Array.isArray(result.current.balances)).toBe(true); + + const sol = result.current.balances.find((b) => b.isNative); + expect(sol).toBeDefined(); + expect(sol!.spl).toBe(0n); + expect(sol!.decimals).toBe(9); + }); + }); + + describe('useTransactionHistory', () => { + it('returns empty for address with no history', async () => { + const { result } = renderHook(() => useTransactionHistory()); + + await act(async () => { + await result.current.fetchTransactionHistory(TEST_ADDRESS, 5); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.transactions).toEqual([]); + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/toolkits/sign-with-privy/react/src/hooks/__tests__/mock-rpc.ts b/toolkits/sign-with-privy/react/src/hooks/__tests__/mock-rpc.ts new file mode 100644 index 0000000..302015a --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/__tests__/mock-rpc.ts @@ -0,0 +1,48 @@ +import { vi } from 'vitest'; +import { PublicKey } from '@solana/web3.js'; + +/** Creates a mock RPC object matching the shape returned by `createRpc`. */ +export function createMockRpc() { + return { + getBalance: vi.fn().mockResolvedValue(1_000_000_000), // 1 SOL + getTokenAccountsByOwner: vi.fn().mockResolvedValue({ value: [] }), + getAccountInfo: vi.fn().mockResolvedValue(null), + getCompressedTokenBalancesByOwnerV2: vi.fn().mockResolvedValue({ + value: { items: [] }, + }), + getSignaturesForOwnerInterface: vi.fn().mockResolvedValue({ + signatures: [], + }), + getLatestBlockhash: vi.fn().mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }), + sendRawTransaction: vi.fn().mockResolvedValue('mock-signature-abc123'), + confirmTransaction: vi.fn().mockResolvedValue({}), + }; +} + +/** Builds a minimal SPL/T22 token account data buffer (72+ bytes). */ +export function buildTokenAccountData(mint: PublicKey, amount: bigint): Buffer { + const buf = Buffer.alloc(72); + mint.toBuffer().copy(buf, 0); // bytes 0-31: mint + // bytes 32-63: owner (unused in tests) + buf.writeBigUInt64LE(amount, 64); // bytes 64-71: amount + return buf; +} + +/** Creates a mock Privy wallet. */ +export function createMockWallet() { + return { + address: '11111111111111111111111111111112', + walletClientType: 'privy', + connectorType: 'embedded', + } as any; +} + +/** Creates a mock signTransaction function. */ +export function createMockSignTransaction() { + return vi.fn().mockResolvedValue({ + signedTransaction: Buffer.alloc(64), // dummy signed bytes + }); +} diff --git a/toolkits/sign-with-privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts b/toolkits/sign-with-privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts new file mode 100644 index 0000000..0156a9b --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/__tests__/useLightTokenBalances.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey } from '@solana/web3.js'; +import { createMockRpc, buildTokenAccountData } from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, +})); + +// Deterministic light-token ATA address for mock comparison +const MOCK_LIGHT_ATA = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); + +const mockGetAtaInterface = vi.fn(); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_LIGHT_ATA, + getAtaInterface: (...args: unknown[]) => mockGetAtaInterface(...args), +})); + +// Import after mocks are hoisted +import { useLightTokenBalances } from '../useLightTokenBalances'; + +const OWNER = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'; + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getBalance.mockResolvedValue(2_500_000_000); // 2.5 SOL + mockRpc.getTokenAccountsByOwner.mockResolvedValue({ value: [] }); + mockRpc.getCompressedTokenBalancesByOwnerV2.mockResolvedValue({ + value: { items: [] }, + }); + // Default: getAtaInterface throws (no ATA exists) + mockGetAtaInterface.mockRejectedValue(new Error('Account not found')); +}); + +describe('useLightTokenBalances', () => { + it('returns empty balances and does not fetch when address is empty', async () => { + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(''); + }); + + expect(result.current.balances).toEqual([]); + expect(mockRpc.getBalance).not.toHaveBeenCalled(); + }); + + it('fetches SOL balance as native entry', async () => { + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const sol = result.current.balances.find((b) => b.isNative); + expect(sol).toBeDefined(); + expect(sol!.spl).toBe(BigInt(2_500_000_000)); + expect(sol!.decimals).toBe(9); + expect(sol!.hot).toBe(0n); + expect(sol!.cold).toBe(0n); + expect(sol!.t22).toBe(0n); + expect(sol!.unified).toBe(0n); + }); + + it('parses SPL token account from raw buffer', async () => { + const mint = PublicKey.unique(); + const data = buildTokenAccountData(mint, 500_000n); + + // First call: SPL accounts, second call: T22 accounts + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ + value: [{ pubkey: PublicKey.unique(), account: { data } }], + }) + .mockResolvedValueOnce({ value: [] }); + + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const spl = result.current.balances.find( + (b) => b.mint === mint.toBase58(), + ); + expect(spl).toBeDefined(); + expect(spl!.spl).toBe(500_000n); + expect(spl!.t22).toBe(0n); + expect(spl!.hot).toBe(0n); + expect(spl!.cold).toBe(0n); + expect(spl!.unified).toBe(0n); + expect(spl!.isNative).toBe(false); + }); + + it('excludes light-token ATA from T22 balance', async () => { + const mint = PublicKey.unique(); + const data = buildTokenAccountData(mint, 1_000_000n); + + // SPL: empty, T22: one account whose pubkey matches the mock light ATA + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ + value: [{ pubkey: MOCK_LIGHT_ATA, account: { data } }], + }); + + // getAtaInterface returns the hot balance for this mint + mockGetAtaInterface.mockResolvedValue({ + parsed: { amount: 1_000_000n }, + }); + + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const entry = result.current.balances.find( + (b) => b.mint === mint.toBase58(), + ); + expect(entry).toBeDefined(); + // T22 balance should be 0 (light-token ATA excluded) + expect(entry!.t22).toBe(0n); + // Hot balance should come from getAtaInterface + expect(entry!.hot).toBe(1_000_000n); + expect(entry!.unified).toBe(1_000_000n); + }); + + it('aggregates hot and cold balances into unified', async () => { + const mint = PublicKey.unique(); + const hotData = buildTokenAccountData(mint, 300_000n); + + // T22 account is a light-token ATA + mockRpc.getTokenAccountsByOwner + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ + value: [{ pubkey: MOCK_LIGHT_ATA, account: { data: hotData } }], + }); + + // Hot balance via getAtaInterface: 300k + mockGetAtaInterface.mockResolvedValue({ + parsed: { amount: 300_000n }, + }); + + // Cold balance: 200k for same mint + mockRpc.getCompressedTokenBalancesByOwnerV2.mockResolvedValue({ + value: { + items: [{ mint, balance: 200_000n }], + }, + }); + + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const entry = result.current.balances.find( + (b) => b.mint === mint.toBase58(), + ); + expect(entry).toBeDefined(); + expect(entry!.hot).toBe(300_000n); + expect(entry!.cold).toBe(200_000n); + // unified = hot + cold + expect(entry!.unified).toBe(500_000n); + expect(entry!.t22).toBe(0n); + }); + + it('adds standalone cold balance when no hot ATA exists', async () => { + const coldMint = PublicKey.unique(); + + mockRpc.getTokenAccountsByOwner.mockResolvedValue({ value: [] }); + mockRpc.getCompressedTokenBalancesByOwnerV2.mockResolvedValue({ + value: { + items: [{ mint: coldMint, balance: 750_000n }], + }, + }); + + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + const cold = result.current.balances.find( + (b) => b.mint === coldMint.toBase58(), + ); + expect(cold).toBeDefined(); + expect(cold!.cold).toBe(750_000n); + expect(cold!.hot).toBe(0n); + // unified = hot(0) + cold(750k) = 750k + expect(cold!.unified).toBe(750_000n); + expect(cold!.spl).toBe(0n); + expect(cold!.t22).toBe(0n); + }); + + it('sets isLoading during fetch and clears after', async () => { + const { result } = renderHook(() => useLightTokenBalances()); + + expect(result.current.isLoading).toBe(false); + + let resolveBalance!: (v: number) => void; + mockRpc.getBalance.mockReturnValue( + new Promise((r) => { + resolveBalance = r; + }), + ); + + const fetchPromise = act(async () => { + const p = result.current.fetchBalances(OWNER); + return p; + }); + + resolveBalance(1_000_000_000); + await fetchPromise; + + expect(result.current.isLoading).toBe(false); + }); + + it('returns empty balances on complete RPC failure', async () => { + mockRpc.getBalance.mockRejectedValue(new Error('RPC down')); + mockRpc.getTokenAccountsByOwner.mockRejectedValue(new Error('RPC down')); + mockRpc.getCompressedTokenBalancesByOwnerV2.mockRejectedValue( + new Error('RPC down'), + ); + + const { result } = renderHook(() => useLightTokenBalances()); + + await act(async () => { + await result.current.fetchBalances(OWNER); + }); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-privy/react/src/hooks/__tests__/useTransactionHistory.test.ts b/toolkits/sign-with-privy/react/src/hooks/__tests__/useTransactionHistory.test.ts new file mode 100644 index 0000000..517018a --- /dev/null +++ b/toolkits/sign-with-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/toolkits/sign-with-privy/react/src/hooks/__tests__/useTransfer.test.ts b/toolkits/sign-with-privy/react/src/hooks/__tests__/useTransfer.test.ts new file mode 100644 index 0000000..2c250ef --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/__tests__/useTransfer.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockWallet, + createMockSignTransaction, +} from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, +})); + +const dummyIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), +}); + +const mockCreateTransferInterfaceInstructions = vi.fn(); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + createTransferInterfaceInstructions: (...args: unknown[]) => + mockCreateTransferInterfaceInstructions(...args), +})); + +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +import { useTransfer } from '../useTransfer'; + +const wallet = createMockWallet(); +const signTransaction = createMockSignTransaction(); + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getLatestBlockhash.mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }); + mockRpc.sendRawTransaction.mockResolvedValue('tx-sig-transfer'); + signTransaction.mockResolvedValue({ + signedTransaction: Buffer.alloc(64), + }); + // Default: single batch with one instruction + mockCreateTransferInterfaceInstructions.mockResolvedValue([[dummyIx]]); +}); + +describe('useTransfer', () => { + it('builds, signs, and sends a transfer transaction', async () => { + const { result } = renderHook(() => useTransfer()); + + let sig: string | undefined; + await act(async () => { + sig = await result.current.transfer({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1.5, + decimals: 9, + }, + wallet, + signTransaction, + }); + }); + + expect(sig).toBe('tx-sig-transfer'); + expect(mockCreateTransferInterfaceInstructions).toHaveBeenCalledOnce(); + expect(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('handles multiple instruction batches', async () => { + mockCreateTransferInterfaceInstructions.mockResolvedValue([ + [dummyIx], + [dummyIx], + ]); + + const { result } = renderHook(() => useTransfer()); + + await act(async () => { + await result.current.transfer({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + + expect(signTransaction).toHaveBeenCalledTimes(2); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledTimes(2); + }); + + it('manages isLoading state', async () => { + const { result } = renderHook(() => useTransfer()); + expect(result.current.isLoading).toBe(false); + + await act(async () => { + await result.current.transfer({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('propagates RPC errors', async () => { + mockRpc.sendRawTransaction.mockRejectedValue( + new Error('Transaction failed'), + ); + + const { result } = renderHook(() => useTransfer()); + + await expect( + act(async () => { + await result.current.transfer({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + toAddress: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }), + ).rejects.toThrow('Transaction failed'); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-privy/react/src/hooks/__tests__/useUnwrap.test.ts b/toolkits/sign-with-privy/react/src/hooks/__tests__/useUnwrap.test.ts new file mode 100644 index 0000000..95cdff0 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/__tests__/useUnwrap.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockWallet, + createMockSignTransaction, +} from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, +})); + +const dummyIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), +}); + +const mockCreateUnwrapInstructions = vi.fn(); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + createUnwrapInstructions: (...args: unknown[]) => + mockCreateUnwrapInstructions(...args), +})); + +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +// Keep real getAssociatedTokenAddressSync +vi.mock('@solana/spl-token', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); + +import { useUnwrap } from '../useUnwrap'; + +const wallet = createMockWallet(); +const signTransaction = createMockSignTransaction(); + +const TOKEN_2022_PROGRAM_ID = new PublicKey( + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +); + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getLatestBlockhash.mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }); + mockRpc.sendRawTransaction.mockResolvedValue('tx-sig-unwrap'); + signTransaction.mockResolvedValue({ + signedTransaction: Buffer.alloc(64), + }); + // Default: mint account exists with T22 as owner + mockRpc.getAccountInfo.mockResolvedValue({ owner: TOKEN_2022_PROGRAM_ID }); + // Default: single batch with one instruction + mockCreateUnwrapInstructions.mockResolvedValue([[dummyIx]]); +}); + +describe('useUnwrap', () => { + it('builds, signs, and sends an unwrap transaction', async () => { + const { result } = renderHook(() => useUnwrap()); + + let sig: string | undefined; + await act(async () => { + sig = await result.current.unwrap({ + params: { + ownerPublicKey: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 2, + decimals: 9, + }, + wallet, + signTransaction, + }); + }); + + expect(sig).toBe('tx-sig-unwrap'); + expect(mockCreateUnwrapInstructions).toHaveBeenCalledOnce(); + expect(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('handles multiple instruction batches', async () => { + mockCreateUnwrapInstructions.mockResolvedValue([ + [dummyIx], + [dummyIx], + ]); + + const { result } = renderHook(() => useUnwrap()); + + await act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + + expect(signTransaction).toHaveBeenCalledTimes(2); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledTimes(2); + }); + + it('propagates SDK errors', async () => { + mockCreateUnwrapInstructions.mockRejectedValue( + new Error('Unwrap failed'), + ); + + const { result } = renderHook(() => useUnwrap()); + + await expect( + act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }), + ).rejects.toThrow('Unwrap failed'); + + expect(result.current.isLoading).toBe(false); + }); + + it('clears isLoading after completion', async () => { + const { result } = renderHook(() => useUnwrap()); + + await act(async () => { + await result.current.unwrap({ + params: { + ownerPublicKey: Keypair.generate().publicKey.toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-privy/react/src/hooks/__tests__/useWrap.test.ts b/toolkits/sign-with-privy/react/src/hooks/__tests__/useWrap.test.ts new file mode 100644 index 0000000..3619ab9 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/__tests__/useWrap.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + createMockRpc, + createMockWallet, + createMockSignTransaction, +} from './mock-rpc'; + +const mockRpc = createMockRpc(); + +vi.mock('@lightprotocol/stateless.js', () => ({ + createRpc: () => mockRpc, + CTOKEN_PROGRAM_ID: new PublicKey('11111111111111111111111111111111'), +})); + +const MOCK_LIGHT_ATA = PublicKey.unique(); +const dummyIx = new TransactionInstruction({ + keys: [], + programId: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), +}); + +const mockGetSplInterfaceInfos = vi.fn(); + +vi.mock('@lightprotocol/compressed-token', () => ({ + getSplInterfaceInfos: (...args: unknown[]) => + mockGetSplInterfaceInfos(...args), +})); + +vi.mock('@lightprotocol/compressed-token/unified', () => ({ + getAssociatedTokenAddressInterface: () => MOCK_LIGHT_ATA, + createWrapInstruction: vi.fn(() => dummyIx), + createAssociatedTokenAccountInterfaceIdempotentInstruction: vi.fn(() => dummyIx), +})); + +vi.mock('@privy-io/react-auth/solana', () => ({ + useSignTransaction: () => ({ signTransaction: async () => ({}) }), +})); + +// Partial mock: keep real getAssociatedTokenAddressSync, mock getAccount +const mockGetAccount = vi.fn(); +vi.mock('@solana/spl-token', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAccount: (...args: unknown[]) => mockGetAccount(...args) }; +}); + +import { useWrap } from '../useWrap'; + +const wallet = createMockWallet(); +const signTransaction = createMockSignTransaction(); +const TOKEN_2022_PROGRAM_ID = new PublicKey( + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +); + +beforeEach(() => { + vi.clearAllMocks(); + mockRpc.getLatestBlockhash.mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 100, + }); + mockRpc.sendRawTransaction.mockResolvedValue('tx-sig-wrap'); + signTransaction.mockResolvedValue({ + signedTransaction: Buffer.alloc(64), + }); + // Default: initialized SPL interface with T22 + mockGetSplInterfaceInfos.mockResolvedValue([ + { isInitialized: true, tokenProgram: TOKEN_2022_PROGRAM_ID }, + ]); + // Default: ATA exists with sufficient balance + mockGetAccount.mockResolvedValue({ amount: 10_000_000_000n }); +}); + +describe('useWrap', () => { + it('builds, signs, and sends a wrap transaction', async () => { + const { result } = renderHook(() => useWrap()); + + let sig: string | undefined; + await act(async () => { + sig = await result.current.wrap({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 5, + decimals: 9, + }, + wallet, + signTransaction, + }); + }); + + expect(sig).toBe('tx-sig-wrap'); + expect(mockGetSplInterfaceInfos).toHaveBeenCalled(); + expect(signTransaction).toHaveBeenCalledOnce(); + expect(mockRpc.sendRawTransaction).toHaveBeenCalledOnce(); + }); + + it('throws when no SPL interface is found', async () => { + mockGetSplInterfaceInfos.mockResolvedValue([ + { isInitialized: false, tokenProgram: TOKEN_2022_PROGRAM_ID }, + ]); + + const { result } = renderHook(() => useWrap()); + + await expect( + act(async () => { + await result.current.wrap({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }), + ).rejects.toThrow('No SPL interface found'); + }); + + it('throws when SPL balance is insufficient', async () => { + mockGetAccount.mockResolvedValue({ amount: 100n }); + + const { result } = renderHook(() => useWrap()); + + await expect( + result.current.wrap({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + decimals: 9, + }, + wallet, + signTransaction, + }), + ).rejects.toThrow(); + + expect(mockRpc.sendRawTransaction).not.toHaveBeenCalled(); + }); + + it('clears isLoading on error', async () => { + mockGetSplInterfaceInfos.mockRejectedValue(new Error('RPC fail')); + + const { result } = renderHook(() => useWrap()); + + try { + await act(async () => { + await result.current.wrap({ + params: { + ownerPublicKey: PublicKey.unique().toBase58(), + mint: PublicKey.unique().toBase58(), + amount: 1, + }, + wallet, + signTransaction, + }); + }); + } catch { + // expected + } + + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/toolkits/sign-with-privy/react/src/hooks/index.ts b/toolkits/sign-with-privy/react/src/hooks/index.ts new file mode 100644 index 0000000..038828e --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/index.ts @@ -0,0 +1,5 @@ +export { useTransfer } from './useTransfer'; +export { useWrap } from './useWrap'; +export { useUnwrap } from './useUnwrap'; +export { useLightTokenBalances } from './useLightTokenBalances'; +export { useTransactionHistory } from './useTransactionHistory'; diff --git a/toolkits/sign-with-privy/react/src/hooks/signAndSendBatches.ts b/toolkits/sign-with-privy/react/src/hooks/signAndSendBatches.ts new file mode 100644 index 0000000..99b6517 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/signAndSendBatches.ts @@ -0,0 +1,44 @@ +import { Transaction, TransactionInstruction, PublicKey } from '@solana/web3.js'; +import type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; + +type SignTransactionFn = ReturnType['signTransaction']; + +interface SignAndSendOptions { + rpc: any; + feePayer: PublicKey; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export async function signAndSendBatches( + instructionBatches: TransactionInstruction[][], + options: SignAndSendOptions, +): Promise { + const { rpc, feePayer, wallet, signTransaction } = options; + const signatures: string[] = []; + + for (const ixs of instructionBatches) { + const tx = new Transaction().add(...ixs); + const { blockhash } = await rpc.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = feePayer; + + const unsignedTxBuffer = tx.serialize({ requireAllSignatures: false }); + const signedTx = await signTransaction({ + transaction: unsignedTxBuffer, + wallet, + chain: 'solana:devnet', + }); + + const signedTxBuffer = Buffer.from(signedTx.signedTransaction); + const sig = await rpc.sendRawTransaction(signedTxBuffer, { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + await rpc.confirmTransaction(sig, 'confirmed'); + signatures.push(sig); + } + + return signatures.length > 0 ? signatures[signatures.length - 1] : null; +} diff --git a/toolkits/sign-with-privy/react/src/hooks/useLightTokenBalances.ts b/toolkits/sign-with-privy/react/src/hooks/useLightTokenBalances.ts new file mode 100644 index 0000000..4125607 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/useLightTokenBalances.ts @@ -0,0 +1,169 @@ +import { useState, useCallback } from 'react'; +import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import { createRpc } from '@lightprotocol/stateless.js'; +import { + getAssociatedTokenAddressInterface, + getAtaInterface, +} from '@lightprotocol/compressed-token/unified'; + +export interface TokenBalance { + mint: string; + decimals: number; + isNative: boolean; + hot: bigint; + cold: bigint; + spl: bigint; + t22: bigint; + unified: bigint; +} + +export function useLightTokenBalances() { + const [balances, setBalances] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchBalances = useCallback(async (ownerAddress: string) => { + if (!ownerAddress) return; + + setIsLoading(true); + try { + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + const owner = new PublicKey(ownerAddress); + + // Per-mint accumulator + const mintMap = new Map(); + + const getOrCreate = (mintStr: string) => { + let entry = mintMap.get(mintStr); + if (!entry) { + entry = { spl: 0n, t22: 0n, hot: 0n, cold: 0n, decimals: 9 }; + mintMap.set(mintStr, entry); + } + return entry; + }; + + // Track which mints we found via T22 light-token ATAs (skip in t22 accumulation) + const lightAtaMints = new Set(); + + // 1. SOL balance + let solLamports = 0; + try { + solLamports = await rpc.getBalance(owner); + } catch { + // Failed to fetch SOL balance + } + + // 2. SPL accounts (standard token program) + try { + const splAccounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_PROGRAM_ID, + }); + for (const { account } of splAccounts.value) { + const buf = toBuffer(account.data); + if (!buf || buf.length < 72) continue; + const mint = new PublicKey(buf.subarray(0, 32)); + const amount = buf.readBigUInt64LE(64); + const mintStr = mint.toBase58(); + getOrCreate(mintStr).spl += amount; + } + } catch { + // No SPL accounts + } + + // 3. T22 accounts (Token-2022) + try { + const t22Accounts = await rpc.getTokenAccountsByOwner(owner, { + programId: TOKEN_2022_PROGRAM_ID, + }); + for (const { pubkey, account } of t22Accounts.value) { + const buf = toBuffer(account.data); + if (!buf || buf.length < 72) continue; + const mint = new PublicKey(buf.subarray(0, 32)); + const mintStr = mint.toBase58(); + const expectedAta = getAssociatedTokenAddressInterface(mint, owner); + if (pubkey.equals(expectedAta)) { + // This is a light-token ATA — will query via getAtaInterface instead + lightAtaMints.add(mintStr); + getOrCreate(mintStr); // ensure entry exists for hot balance query + } else { + const amount = buf.readBigUInt64LE(64); + getOrCreate(mintStr).t22 += amount; + } + } + } catch { + // No T22 accounts + } + + // 4. Hot balance via getAtaInterface (parallelize per mint) + const mintKeys = [...mintMap.keys()]; + await Promise.allSettled( + mintKeys.map(async (mintStr) => { + try { + const mint = new PublicKey(mintStr); + const ata = getAssociatedTokenAddressInterface(mint, owner); + const { parsed } = await getAtaInterface(rpc, ata, owner, mint); + const entry = getOrCreate(mintStr); + entry.hot = BigInt(parsed.amount.toString()); + } catch { + // ATA does not exist for this mint — hot stays 0n + } + }), + ); + + // 5. Cold balance (compressed token accounts) + try { + const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner); + for (const item of compressed.value.items) { + const mintStr = item.mint.toBase58(); + getOrCreate(mintStr).cold += BigInt(item.balance.toString()); + } + } catch { + // No compressed accounts + } + + // 6. Assemble TokenBalance[] + const result: TokenBalance[] = []; + + // SOL entry + result.push({ + mint: 'So11111111111111111111111111111111111111112', + decimals: 9, + isNative: true, + hot: 0n, + cold: 0n, + spl: BigInt(solLamports), + t22: 0n, + unified: 0n, + }); + + // Token entries + for (const [mintStr, entry] of mintMap) { + result.push({ + mint: mintStr, + decimals: entry.decimals, + isNative: false, + hot: entry.hot, + cold: entry.cold, + spl: entry.spl, + t22: entry.t22, + unified: entry.hot + entry.cold, + }); + } + + setBalances(result); + } catch (error) { + console.error('Failed to fetch balances:', error); + setBalances([]); + } finally { + setIsLoading(false); + } + }, []); + + return { balances, isLoading, fetchBalances }; +} + +function toBuffer(data: Buffer | Uint8Array | string | unknown): Buffer | null { + if (data instanceof Buffer) return data; + if (data instanceof Uint8Array) return Buffer.from(data); + return null; +} diff --git a/toolkits/sign-with-privy/react/src/hooks/useTransactionHistory.ts b/toolkits/sign-with-privy/react/src/hooks/useTransactionHistory.ts new file mode 100644 index 0000000..1f3a447 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/useTransactionHistory.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { createRpc } from '@lightprotocol/stateless.js'; + +export interface Transaction { + signature: string; + slot: number; + blockTime: number; + timestamp: string; +} + +export function useTransactionHistory() { + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchTransactionHistory = useCallback( + async ( + ownerAddress: string, + limit: number = 10, + ) => { + if (!ownerAddress) { + setTransactions([]); + return; + } + + setIsLoading(true); + setError(null); + + try { + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + const owner = new PublicKey(ownerAddress); + + const result = await rpc.getSignaturesForOwnerInterface(owner); + + if (!result.signatures || result.signatures.length === 0) { + setTransactions([]); + return; + } + + const limitedSignatures = result.signatures.slice(0, limit); + + const basicTransactions = limitedSignatures.map((sig) => ({ + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime ?? 0, + timestamp: sig.blockTime ? new Date(sig.blockTime * 1000).toISOString() : '', + })); + + setTransactions(basicTransactions); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + setTransactions([]); + } finally { + setIsLoading(false); + } + }, + [] + ); + + return { transactions, isLoading, error, fetchTransactionHistory }; +} diff --git a/toolkits/sign-with-privy/react/src/hooks/useTransfer.ts b/toolkits/sign-with-privy/react/src/hooks/useTransfer.ts new file mode 100644 index 0000000..199a6fd --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/useTransfer.ts @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { + createTransferInterfaceInstructions, +} from '@lightprotocol/compressed-token/unified'; +import { createRpc } from '@lightprotocol/stateless.js'; +import type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; +import { signAndSendBatches } from './signAndSendBatches'; + +type SignTransactionFn = ReturnType['signTransaction']; + +export interface TransferParams { + ownerPublicKey: string; + mint: string; + toAddress: string; + amount: number; + decimals?: number; +} + +export interface TransferArgs { + params: TransferParams; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export function useTransfer() { + const [isLoading, setIsLoading] = useState(false); + + const transfer = async (args: TransferArgs): Promise => { + setIsLoading(true); + + try { + const { params, wallet, signTransaction } = args; + const { ownerPublicKey, mint, toAddress, amount, decimals = 9 } = params; + + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + + const owner = new PublicKey(ownerPublicKey); + const mintPubkey = new PublicKey(mint); + const recipient = new PublicKey(toAddress); + const tokenAmount = Math.floor(amount * Math.pow(10, decimals)); + + // Returns TransactionInstruction[][]. + // Each inner array is one transaction. + // Almost always returns just one. + const instructions = await createTransferInterfaceInstructions( + rpc, owner, mintPubkey, tokenAmount, owner, recipient, + ); + + const signature = await signAndSendBatches(instructions, { + rpc, + feePayer: owner, + wallet, + signTransaction, + }); + + if (!signature) { + throw new Error('Transfer returned no instructions'); + } + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { transfer, isLoading }; +} diff --git a/toolkits/sign-with-privy/react/src/hooks/useUnwrap.ts b/toolkits/sign-with-privy/react/src/hooks/useUnwrap.ts new file mode 100644 index 0000000..7cbe8bf --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/useUnwrap.ts @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync } from '@solana/spl-token'; +import { + createUnwrapInstructions, +} from '@lightprotocol/compressed-token/unified'; +import { createRpc } from '@lightprotocol/stateless.js'; +import type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; +import { signAndSendBatches } from './signAndSendBatches'; + +type SignTransactionFn = ReturnType['signTransaction']; + +export interface UnwrapParams { + ownerPublicKey: string; + mint: string; + amount: number; + decimals?: number; +} + +export interface UnwrapArgs { + params: UnwrapParams; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export function useUnwrap() { + const [isLoading, setIsLoading] = useState(false); + + const unwrap = async (args: UnwrapArgs): Promise => { + setIsLoading(true); + + try { + const { params, wallet, signTransaction } = args; + const { ownerPublicKey, mint, amount, decimals = 9 } = params; + + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + + const owner = new PublicKey(ownerPublicKey); + const mintPubkey = new PublicKey(mint); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Auto-detect token program (SPL vs T22) from mint account owner + const mintAccountInfo = await rpc.getAccountInfo(mintPubkey); + if (!mintAccountInfo) throw new Error(`Mint account ${mint} not found`); + const tokenProgramId = mintAccountInfo.owner; + + // Destination: SPL/T22 associated token account + const splAta = getAssociatedTokenAddressSync(mintPubkey, owner, false, tokenProgramId); + + // Returns TransactionInstruction[][]. + // Each inner array is one transaction. + // Handles loading + unwrapping together. + const instructions = await createUnwrapInstructions( + rpc, splAta, owner, mintPubkey, tokenAmount, owner, + ); + + const signature = await signAndSendBatches(instructions, { + rpc, + feePayer: owner, + wallet, + signTransaction, + }); + + if (!signature) { + throw new Error('Unwrap returned no instructions'); + } + + return signature; + } finally { + setIsLoading(false); + } + }; + + return { unwrap, isLoading }; +} diff --git a/toolkits/sign-with-privy/react/src/hooks/useWrap.ts b/toolkits/sign-with-privy/react/src/hooks/useWrap.ts new file mode 100644 index 0000000..34b95b3 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/hooks/useWrap.ts @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { PublicKey, Transaction, ComputeBudgetProgram } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync, getAccount } from '@solana/spl-token'; +import { getSplInterfaceInfos } from '@lightprotocol/compressed-token'; +import { + createWrapInstruction, + getAssociatedTokenAddressInterface, + createAssociatedTokenAccountInterfaceIdempotentInstruction, +} from '@lightprotocol/compressed-token/unified'; +import { createRpc, CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import type { ConnectedStandardSolanaWallet } from '@privy-io/js-sdk-core'; +import { useSignTransaction } from '@privy-io/react-auth/solana'; + +type SignTransactionFn = ReturnType['signTransaction']; + +export interface WrapParams { + ownerPublicKey: string; + mint: string; + amount: number; + decimals?: number; +} + +export interface WrapArgs { + params: WrapParams; + wallet: ConnectedStandardSolanaWallet; + signTransaction: SignTransactionFn; +} + +export function useWrap() { + const [isLoading, setIsLoading] = useState(false); + + const wrap = async (args: WrapArgs): Promise => { + setIsLoading(true); + + try { + const { params, wallet, signTransaction } = args; + const { ownerPublicKey, mint, amount, decimals = 9 } = params; + + const rpc = createRpc(import.meta.env.VITE_HELIUS_RPC_URL); + + const owner = new PublicKey(ownerPublicKey); + const mintPubkey = new PublicKey(mint); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Get SPL interface info — determines whether mint uses SPL or T22 + const splInterfaceInfos = await getSplInterfaceInfos(rpc, mintPubkey); + const splInterfaceInfo = splInterfaceInfos.find( + (info) => info.isInitialized, + ); + if (!splInterfaceInfo) throw new Error('No SPL interface found for this mint'); + const { tokenProgram } = splInterfaceInfo; + + // Derive source associated token account using the mint's token program (SPL or T22) + const splAta = getAssociatedTokenAddressSync(mintPubkey, owner, false, tokenProgram); + const ataAccount = await getAccount(rpc, splAta, undefined, tokenProgram); + if (ataAccount.amount < BigInt(tokenAmount)) { + throw new Error('Insufficient SPL balance'); + } + + // Derive light-token associated token account + const lightTokenAta = getAssociatedTokenAddressInterface(mintPubkey, owner); + + // Build transaction + const tx = new Transaction().add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + createAssociatedTokenAccountInterfaceIdempotentInstruction( + owner, lightTokenAta, owner, mintPubkey, CTOKEN_PROGRAM_ID, + ), + createWrapInstruction( + splAta, lightTokenAta, owner, mintPubkey, + tokenAmount, splInterfaceInfo, decimals, owner, + ), + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + tx.recentBlockhash = blockhash; + tx.feePayer = owner; + + const unsignedTxBuffer = tx.serialize({ requireAllSignatures: false }); + const signedTx = await signTransaction({ + transaction: unsignedTxBuffer, + wallet, + chain: 'solana:devnet', + }); + + const signedTxBuffer = Buffer.from(signedTx.signedTransaction); + return rpc.sendRawTransaction(signedTxBuffer, { + skipPreflight: false, + preflightCommitment: 'confirmed', + }); + } finally { + setIsLoading(false); + } + }; + + return { wrap, isLoading }; +} diff --git a/toolkits/sign-with-privy/react/src/index.css b/toolkits/sign-with-privy/react/src/index.css new file mode 100644 index 0000000..f9e38ba --- /dev/null +++ b/toolkits/sign-with-privy/react/src/index.css @@ -0,0 +1,33 @@ +@import "tailwindcss"; + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* Ensure Privy modal displays above everything */ +#privy-modal-content, +[data-privy-dialog], +[data-radix-portal] { + z-index: 9999 !important; +} + +/* Fix for Privy iframe */ +iframe[src*="privy"] { + z-index: 9999 !important; +} + +.button { + @apply flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer; +} + +.button-primary { + @apply px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed; +} + +.input { + @apply w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent; +} + +.section { + @apply bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6; +} diff --git a/toolkits/sign-with-privy/react/src/main.tsx b/toolkits/sign-with-privy/react/src/main.tsx new file mode 100644 index 0000000..000db4e --- /dev/null +++ b/toolkits/sign-with-privy/react/src/main.tsx @@ -0,0 +1,48 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { PrivyProvider } from '@privy-io/react-auth'; +import { toSolanaWalletConnectors } from '@privy-io/react-auth/solana'; +import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit'; +import App from './App'; +import './index.css'; + +const solanaConnectors = toSolanaWalletConnectors({ + shouldAutoConnect: true, +}); + +const heliusRpcUrl = import.meta.env.VITE_HELIUS_RPC_URL; +const heliusWsUrl = heliusRpcUrl.replace('https://', 'wss://'); + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/toolkits/sign-with-privy/react/src/test-setup.ts b/toolkits/sign-with-privy/react/src/test-setup.ts new file mode 100644 index 0000000..ec24384 --- /dev/null +++ b/toolkits/sign-with-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/toolkits/sign-with-privy/react/src/vite-env.d.ts b/toolkits/sign-with-privy/react/src/vite-env.d.ts new file mode 100644 index 0000000..5cea1e9 --- /dev/null +++ b/toolkits/sign-with-privy/react/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_PRIVY_APP_ID: string; + readonly VITE_HELIUS_RPC_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/toolkits/sign-with-privy/react/tsconfig.json b/toolkits/sign-with-privy/react/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/toolkits/sign-with-privy/react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/toolkits/sign-with-privy/react/tsconfig.node.json b/toolkits/sign-with-privy/react/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/toolkits/sign-with-privy/react/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/toolkits/sign-with-privy/react/vite.config.ts b/toolkits/sign-with-privy/react/vite.config.ts new file mode 100644 index 0000000..71ba862 --- /dev/null +++ b/toolkits/sign-with-privy/react/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +export default defineConfig({ + plugins: [react(), tailwindcss(), nodePolyfills({ include: ['buffer'] })], + test: { + environment: 'happy-dom', + globals: true, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + exclude: ['src/**/integration/**'], + setupFiles: ['src/test-setup.ts'], + }, +}); diff --git a/toolkits/sign-with-privy/react/vitest.integration.config.ts b/toolkits/sign-with-privy/react/vitest.integration.config.ts new file mode 100644 index 0000000..6c319da --- /dev/null +++ b/toolkits/sign-with-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/toolkits/sign-with-privy/scripts/.env.example b/toolkits/sign-with-privy/scripts/.env.example new file mode 100644 index 0000000..685e912 --- /dev/null +++ b/toolkits/sign-with-privy/scripts/.env.example @@ -0,0 +1,3 @@ +HELIUS_RPC_URL= +RECIPIENT_ADDRESS= +TEST_MINT= diff --git a/toolkits/sign-with-privy/scripts/README.md b/toolkits/sign-with-privy/scripts/README.md new file mode 100644 index 0000000..2dba785 --- /dev/null +++ b/toolkits/sign-with-privy/scripts/README.md @@ -0,0 +1,34 @@ +# Setup Scripts + +Standalone setup scripts for creating test mints and funding wallets on devnet. These use a local Solana keypair (`~/.config/solana/id.json`) and don't require Privy credentials. + +Shared by both the [Node.js](../nodejs/) and [React](../react/) examples. + +## Setup + +```bash +npm install +cp .env.example .env +# Set HELIUS_RPC_URL +``` + +## Scripts + +| Command | What it does | +| ------- | ----------- | +| `npm run mint:spl-and-wrap [amount] [decimals]` | Create T22 mint with interface PDA, mint tokens, wrap, and transfer to recipient. Defaults: 100 tokens, 9 decimals. | +| `npm run mint:spl [amount] [decimals]` | Mint SPL/T22 tokens to an existing mint. Defaults: 100 tokens, 9 decimals. | +| `npm run register:spl-interface ` | Register an interface PDA on an existing mint. Required for wrap/unwrap. | + +All scripts accept CLI arguments or fall back to env vars (`RECIPIENT_ADDRESS`, `TEST_MINT`). + +## Example: set up a test token for the React app + +```bash +# 1. Create a funded light-token mint and send tokens to your Privy wallet +npm run mint:spl-and-wrap 100 9 + +# 2. Note the mint address from the output, then start the React app +cd ../react +VITE_HELIUS_RPC_URL=... npm run dev +``` diff --git a/toolkits/sign-with-privy/scripts/package.json b/toolkits/sign-with-privy/scripts/package.json new file mode 100644 index 0000000..e5f9729 --- /dev/null +++ b/toolkits/sign-with-privy/scripts/package.json @@ -0,0 +1,23 @@ +{ + "name": "privy-scripts", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "mint:spl-and-wrap": "tsx src/mint-spl-and-wrap.ts", + "mint:spl-and-wrap:spl": "tsx src/mint-spl-and-wrap.ts --spl", + "mint:spl": "tsx src/mint-spl.ts", + "register:spl-interface": "tsx src/register-spl-interface.ts" + }, + "dependencies": { + "@lightprotocol/compressed-token": "beta", + "@lightprotocol/stateless.js": "beta", + "@solana/spl-token": "^0.4.13", + "@solana/web3.js": "1.98.4", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.8.3" + } +} diff --git a/toolkits/sign-with-privy/scripts/src/mint-spl-and-wrap.ts b/toolkits/sign-with-privy/scripts/src/mint-spl-and-wrap.ts new file mode 100644 index 0000000..b2b2759 --- /dev/null +++ b/toolkits/sign-with-privy/scripts/src/mint-spl-and-wrap.ts @@ -0,0 +1,129 @@ +import 'dotenv/config'; +import {Keypair, PublicKey} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import { + createMintInterface, + createAtaInterfaceIdempotent, + getAssociatedTokenAddressInterface, + wrap, + transferInterface, +} from '@lightprotocol/compressed-token'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createAssociatedTokenAccount, + mintTo, +} from '@solana/spl-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +const createLightTokenMint = async ( + decimals: number, + initialAmount: number, + recipientAddress: string, + tokenProgramId: PublicKey = TOKEN_2022_PROGRAM_ID, +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + // Creates on-chain SPL or T22 mint and registers SPL interface PDA in one transaction. + // SPL interface PDA enables wrap/unwrap between SPL/T22 and light-token. + const mintKeypair = Keypair.generate(); + const { mint, transactionSignature } = await createMintInterface( + connection, + payer, + payer, + null, + decimals, + mintKeypair, + undefined, + tokenProgramId, + ); + + console.log('Mint:', mint.toBase58()); + console.log('Create signature:', transactionSignature); + + if (initialAmount > 0) { + const recipient = new PublicKey(recipientAddress); + const tokenAmount = BigInt(Math.floor(initialAmount * Math.pow(10, decimals))); + + // 1. Mint SPL/T22 tokens to payer's associated token account (payer can sign) + const payerSplAta = await createAssociatedTokenAccount( + connection, + payer, + mint, + payer.publicKey, + undefined, + tokenProgramId, + ); + await mintTo( + connection, + payer, + mint, + payerSplAta, + payer, + tokenAmount, + [], + undefined, + tokenProgramId, + ); + console.log('Minted', initialAmount, 'tokens'); + + // 2. Wrap SPL/T22 → payer's light associated token account + await createAtaInterfaceIdempotent(connection, payer, mint, payer.publicKey); + const payerLightAta = getAssociatedTokenAddressInterface(mint, payer.publicKey); + await wrap(connection, payer, payerSplAta, payerLightAta, payer, mint, tokenAmount); + console.log('Wrapped into payer light associated token account'); + + // 3. Transfer payer's light associated token account → recipient's light associated token account + await createAtaInterfaceIdempotent(connection, payer, mint, recipient); + const recipientLightAta = getAssociatedTokenAddressInterface(mint, recipient); + await transferInterface( + connection, + payer, + payerLightAta, + mint, + recipientLightAta, + payer, + tokenAmount, + ); + console.log('Transferred', initialAmount, 'tokens to', recipientAddress); + } + + return { + mintAddress: mint.toBase58(), + signature: transactionSignature, + }; +}; + +export default createLightTokenMint; + +// --- main --- +// Usage: npm run mint:spl-and-wrap [recipient] [amount] [decimals] +// npm run mint:spl-and-wrap:spl [recipient] [amount] [decimals] +// Falls back to RECIPIENT_ADDRESS env var. +// Pass --spl to create an SPL mint instead of T22. +const useSpl = process.argv.includes('--spl'); +const positionalArgs = process.argv.slice(2).filter(a => a !== '--spl'); +const recipient = positionalArgs[0] || process.env.RECIPIENT_ADDRESS; +const amount = Number(positionalArgs[1] || 100); +const decimals = Number(positionalArgs[2] || 9); +const tokenProgramId = useSpl ? TOKEN_PROGRAM_ID : TOKEN_2022_PROGRAM_ID; + +if (!recipient) { + console.error('Usage: npm run mint:spl-and-wrap [amount] [decimals]'); + console.error(' npm run mint:spl-and-wrap:spl [amount] [decimals]'); + console.error(' or set RECIPIENT_ADDRESS in .env'); + process.exit(1); +} + +console.log('Token program:', useSpl ? 'SPL' : 'T22'); +createLightTokenMint(decimals, amount, recipient, tokenProgramId) + .then((result) => console.log('Mint result:', result)) + .catch(console.error); diff --git a/toolkits/sign-with-privy/scripts/src/mint-spl.ts b/toolkits/sign-with-privy/scripts/src/mint-spl.ts new file mode 100644 index 0000000..d84829d --- /dev/null +++ b/toolkits/sign-with-privy/scripts/src/mint-spl.ts @@ -0,0 +1,100 @@ +import 'dotenv/config'; +import {Keypair, PublicKey, Transaction, ComputeBudgetProgram, sendAndConfirmTransaction} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import { + getAssociatedTokenAddressSync, + createAssociatedTokenAccountInstruction, + createMintToInstruction, + getAccount, + TokenAccountNotFoundError, +} from '@solana/spl-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +const mintSplTokens = async ( + mintAddress: string, + recipientAddress: string, + amount: number, + decimals: number, +) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + const mint = new PublicKey(mintAddress); + const recipient = new PublicKey(recipientAddress); + const tokenAmount = BigInt(Math.floor(amount * Math.pow(10, decimals))); + + // Auto-detect token program (SPL vs T22) from mint account owner + const mintAccountInfo = await connection.getAccountInfo(mint); + if (!mintAccountInfo) throw new Error(`Mint account ${mintAddress} not found`); + const tokenProgramId = mintAccountInfo.owner; + console.log('Detected token program:', tokenProgramId.toBase58()); + + // Derive recipient associated token account for the given token program (SPL or T22) + const recipientAta = getAssociatedTokenAddressSync(mint, recipient, false, tokenProgramId); + + // Build transaction + const transaction = new Transaction(); + transaction.add(ComputeBudgetProgram.setComputeUnitLimit({units: 300_000})); + + // Create associated token account if it doesn't exist + try { + await getAccount(connection, recipientAta, undefined, tokenProgramId); + } catch (e) { + if (e instanceof TokenAccountNotFoundError) { + transaction.add( + createAssociatedTokenAccountInstruction(payer.publicKey, recipientAta, recipient, mint, tokenProgramId) + ); + } else { + throw e; + } + } + + // Add mint-to instruction + transaction.add( + createMintToInstruction( + mint, + recipientAta, + payer.publicKey, + tokenAmount, + [], + tokenProgramId, + ) + ); + + // Send transaction + const signature = await sendAndConfirmTransaction( + connection, + transaction, + [payer], + {commitment: 'confirmed'} + ); + + return signature; +}; + +export default mintSplTokens; + +// --- main --- +// Usage: npm run mint:spl [amount] [decimals] +// Falls back to TEST_MINT and RECIPIENT_ADDRESS env vars. +const mint = process.argv[2] || process.env.TEST_MINT; +const recipient = process.argv[3] || process.env.RECIPIENT_ADDRESS; +const amount = Number(process.argv[4] || 100); +const decimals = Number(process.argv[5] || 9); + +if (!mint || !recipient) { + console.error('Usage: npm run mint:spl [amount] [decimals]'); + console.error(' or set TEST_MINT and RECIPIENT_ADDRESS in .env'); + process.exit(1); +} + +mintSplTokens(mint, recipient, amount, decimals) + .then((result) => console.log('Mint SPL signature:', result)) + .catch(console.error); diff --git a/toolkits/sign-with-privy/scripts/src/register-spl-interface.ts b/toolkits/sign-with-privy/scripts/src/register-spl-interface.ts new file mode 100644 index 0000000..5774218 --- /dev/null +++ b/toolkits/sign-with-privy/scripts/src/register-spl-interface.ts @@ -0,0 +1,45 @@ +import 'dotenv/config'; +import {Keypair, PublicKey} from '@solana/web3.js'; +import {createRpc} from '@lightprotocol/stateless.js'; +import {createSplInterface} from '@lightprotocol/compressed-token'; +import {homedir} from 'os'; +import {readFileSync} from 'fs'; + +// Add to existing mints an SPL interface PDA to enable interop with Light Tokens. +// Interface PDA holds SPL/T22 tokens when wrapped to light-token. +const registerSplInterface = async (mintAddress: string, tokenProgramId?: PublicKey) => { + const connection = createRpc(process.env.HELIUS_RPC_URL!); + + // Load filesystem wallet + const payer = Keypair.fromSecretKey( + new Uint8Array( + JSON.parse(readFileSync(`${homedir()}/.config/solana/id.json`, 'utf8')) + ) + ); + + const mint = new PublicKey(mintAddress); + const tx = await createSplInterface(connection, payer, mint, undefined, tokenProgramId); + + console.log('Mint:', mint.toBase58()); + console.log('Register SPL interface signature:', tx); + + return tx; +}; + +export {registerSplInterface}; +export default registerSplInterface; + +// --- main --- +// Usage: npm run register:spl-interface +// Falls back to TEST_MINT env var. +const mint = process.argv[2] || process.env.TEST_MINT; + +if (!mint) { + console.error('Usage: npm run register:spl-interface '); + console.error(' or set TEST_MINT in .env'); + process.exit(1); +} + +registerSplInterface(mint) + .then((result) => console.log('Result:', result)) + .catch(console.error); diff --git a/toolkits/sign-with-privy/scripts/tsconfig.json b/toolkits/sign-with-privy/scripts/tsconfig.json new file mode 100644 index 0000000..10aedef --- /dev/null +++ b/toolkits/sign-with-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"] +}